Ruby Logging Guide: How to Use the Built-in Logger Class
Every Ruby application needs to keep a record of events as they happen. The standard library makes Ruby logging straightforward with the Logger class, a battle-tested tool that handles log levels, formatting, and file rotation out of the box. This guide covers everything you need to write clear, maintainable logs in any Ruby project.
TL;DR
Use Logger when you want a standard, dependable way to record events without pulling in extra gems. Start with a simple destination like STDOUT or a file, pick a sensible level for the environment, and make sure the message only gets built when it will actually be logged. Once the basics are in place, formatting and rotation make the output much easier to manage.
Creating a Logger
The most common way to create a logger is with Logger.new, passing a filename:
logger = Logger.new("app.log")
logger.info("Application started")
# => I, [2026-03-31T10:00:00.000000 #12345] INFO -- : Application started
logger.close
You can also log to standard output by passing STDOUT. Logging directly to stdout is common during development because the messages appear inline with the rest of your terminal output. It also works well in containerised environments where the platform aggregates stdout and stderr automatically, removing the need to manage log files inside the container:
logger = Logger.new(STDOUT)
logger.info("Printing to stdout")
# => I, [2026-03-31T10:00:00.000000 #12345] INFO -- : Printing to stdout
Or create a logger that writes to a device with a given mode. The constructor accepts several forms. Passing a single string creates an append-mode file logger, while passing a file descriptor like STDOUT skips file management entirely. When you need more control, supply the filename, a shift age for rotation, and a shift size to limit how large each file grows before rotation starts:
# Append to existing file
logger = Logger.new("app.log", "app.log", 10)
# Truncate the file on open
logger = Logger.new("app.log", 0) # '0' means truncate
The first decision is usually where the messages should go. A file is easy to inspect later, while STDOUT is often better in containerized deployments where the platform handles collection. Once that choice is made, the rest of the logger configuration mostly shapes how much detail you want and how long you want to keep it.
Log Levels
Logger defines six log levels, from least to most severe:
| Level | Constant | When to use |
|---|---|---|
DEBUG | Logger::DEBUG | Detailed diagnostic information |
INFO | Logger::INFO | General runtime events |
WARN | Logger::WARN | Something unexpected, but not fatal |
ERROR | Logger::ERROR | A serious problem that prevented something |
FATAL | Logger::FATAL | A crash is about to happen |
UNKNOWN | Logger::UNKNOWN | Any message at all |
The default level is INFO. Any messages below the current level are ignored.
That filtering is what keeps logs readable. In development you often want more detail so you can see the full path through the code. In production, you usually want fewer messages so the important ones do not disappear into noise. The level lets you change that balance without changing the log calls themselves.
logger = Logger.new(STDOUT)
logger.level = Logger::DEBUG
logger.debug("Debug message") # printed
logger.info("Info message") # printed
logger.warn("Warning") # printed
logger.error("Error!") # printed
# => D, [2026-03-31T10:00:00.000000 #12345] DEBUG -- : Debug message
# => I, [2026-03-31T10:00:00.000000 #12345] INFO -- : Info message
# => W, [2026-03-31T10:00:00.000000 #12345] WARN -- : Warning
# => E, [2026-03-31T10:00:00.000000 #12345] ERROR -- : Error!
To silence everything below a certain level. Raising the level to WARN hides debug and info messages, which is useful in production when you want to surface only actionable events without scrolling through diagnostic noise from the lower severities:
logger.level = Logger::WARN
logger.debug("Ignored") # not printed
logger.warn("Shown") # printed
A level filter is especially useful when the same code runs in several environments. You can keep the log statements in the source and still decide, per deployment, how much of that trace should actually be visible.
Writing log messages
The primary methods map directly to log levels. Each severity method (like debug, info, warn, error, fatal, and unknown) writes a formatted line to the underlying log device. The method name determines which severity label appears in the output, making it straightforward to search for warnings or errors later:
logger.debug("Something small")
logger.info("Normal event")
logger.warn("Watch out")
logger.error("Something went wrong")
logger.fatal("Dying!")
logger.unknown("This could be anything")
Logger#add
Logger#add (aliased as Logger#log) takes a level constant as the first argument, giving you programmatic control over the severity at runtime. This flexibility is handy when the log level depends on a condition that is not known until the code executes:
logger.add(Logger::INFO) { "Message inside a block" }
# => I, [2026-03-31T10:00:00.000000 #12345] INFO -- : Message inside a block
Passing a block is efficient because the message string is only built if the log level passes the threshold. This lazy evaluation means expensive string interpolation only runs when the logger actually needs the output, which can matter in hot code paths or when the message involves a database query.
Logger#<<
Logger#<< writes a raw message exactly as given, without adding a severity marker, timestamp, or progname:
logger = Logger.new(STDOUT)
logger << "Plain text without formatting\n"
# => Plain text without formatting
This is useful for dumping raw output directly into the log stream. You might reach for it when piping pre-formatted output from another tool, or when the application already structures the data and the default Logger framing would add unnecessary noise to each line.
Raw logging is a small escape hatch. It is fine when another system already formats the message or when you need to copy a payload without extra noise. For most application logs, though, the standard severity and timestamp fields are better because they make searching and filtering easier later.
Formatting
By default, each log line includes the severity, timestamp, process ID, progname, and the message. You can customise this with Logger#formatter=:
logger = Logger.new(STDOUT)
logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} — #{msg}\n"
end
logger.info("User logged in")
# => [2026-03-31 10:00:00] INFO — User logged in
The formatter receives five arguments: severity (string), datetime (Time), progname (string or nil), and the message (typically a msg argument that may be a String or an object).
That hook is where logging becomes much more than plain text. You can standardize the format across the app, add metadata for log aggregation, or emit JSON when a machine will read the output. The goal is not to make the logger clever; it is to make the output predictable enough that another tool can consume it later.
The progname Argument
The progname is a string that identifies the part of your application generating the log. Set it in the constructor or via Logger#progname=:
logger = Logger.new(STDOUT)
logger.progname = "PaymentService"
logger.info("Charge processed")
# => I, [2026-03-31T10:00:00.000000 #12345] INFO -- PaymentService : Charge processed
You can also override it per-call. When you pass a string as the first argument to a log method, Logger treats it as the progname for that single entry. This is a quick way to tag individual log lines with contextual information without creating separate logger instances for each subsystem of your application:
logger.info("EmailService") { "Email sent" }
Custom datetime format
The default timestamp includes milliseconds and the process ID, which is precise but verbose. To change the timestamp format, pass a datetime_format argument to the constructor. Any format string accepted by Time#strftime works here, so you can match the conventions of your existing log pipeline:
logger = Logger.new("app.log", datetime_format: "%Y-%m-%d")
logger.info("With custom date format")
# => I, [2026-03-31] INFO -- : With custom date format
Building custom loggers
You can subclass Logger to add your own behaviour. A custom logger lets you set defaults once, like the formatter and log level, so every part of the application gets consistent output without repeating configuration. You can also add domain-specific helper methods that wrap the standard severity calls:
class AppLogger < Logger
def initialize(path)
super(path)
self.level = Logger::INFO
self formatter = ->(severity, datetime, progname, msg) {
"[#{Process.pid}] #{severity} | #{msg}\n"
}
end
def log_request(user_id, action)
info("user_id=#{user_id} action=#{action}")
end
end
logger = AppLogger.new("requests.log")
logger.log_request(42, "create")
# => [12345] INFO | user_id=42 action=create
Or extend an existing logger with refinements or monkey-patches if subclassing feels too heavy.
Log Rotation
Over time log files grow large. Logger supports two rotation strategies.
Size-based log rotation with Logger::LogDevice
Pass a shift age and shift size to the constructor. The shift_age argument controls how many old log files to keep, and shift_size sets the byte threshold that triggers rotation. Once the current log file reaches that size, Logger closes it and starts a new one:
logger = Logger.new("app.log", shift_age: 5, shift_size = 1_048_576)
# Keeps 5 files of up to 1 MB each: app.log, app.log.0, app.log.1, ..., app.log.4
When the file exceeds 1 MB, Logger closes the current file, renames it to app.log.0, and opens a fresh app.log. Size-based rotation works well when throughput is predictable, because you can estimate how long a given file size will last before it fills up.
Time-Based Rotation
Time-based rotation organises logs by calendar period rather than by byte count. For daily rotation, use Logger::LogDevice directly:
require "logger"
logdev = Logger::LogDevice.new(
"logs/app.log",
shift_age: "daily",
shift_size: 0
)
logger = Logger.new(logdev)
logger.info("Daily rotation active")
Other supported shift_age values include "weekly" and "monthly", or an integer specifying the maximum number of shift files to keep.
Rotation is easy to ignore until a log file grows large enough to hurt startup or fill disk space. Setting it early avoids a class of maintenance problems that are annoying to discover later. The exact policy can be simple, as long as it keeps the files bounded and you know where old entries end up.
Safely opening a log file
Always close the logger when you are done, or use a block form that handles it automatically:
Logger.open("app.log") do |log|
log.info("Inside the block")
end
# Logger is closed automatically here
Logging to Files vs STDOUT
Standard output is good for development. Messages appear immediately in your terminal and do not persist. When you run a script or a Rack server locally, seeing log lines interleaved with the application output makes debugging faster because you can trace the timeline without switching windows:
logger = Logger.new(STDOUT)
logger.warn("Temporary warning")
# Easy to read during development
Files are for production. Logs persist across restarts and can be analysed later with tools like grep, awk, or a centralised log aggregator. File-based logging also survives process restarts, making it the default choice for long-running services that need an audit trail:
logger = Logger.new("/var/log/myapp/app.log")
logger.info("Production event")
In production, consider sending logs to STDOUT and letting your deployment system (e.g., Docker, systemd) capture them. This keeps your application decoupled from log file management.
This is one of the easiest ways to make logging portable. The app writes messages, and the runtime decides where those messages go. That separation keeps the code simpler and makes it easier to switch between local development, containers, and a hosted platform without rewriting the logging setup.
Best Practices
Set the level appropriately per environment. Use DEBUG in development and WARN or ERROR in production to avoid log spam:
case ENV["RACK_ENV"]
when "production" then Logger.new("app.log").level = Logger::WARN
else Logger.new(STDOUT).level = Logger::DEBUG
end
Use blocks for expensive message construction. Ruby’s Logger only evaluates the block if the message will actually be logged. This lazy evaluation pattern avoids building strings that would be discarded by the level filter, which matters when message construction involves database queries or serialisation of large objects:
# Only builds the string if level is DEBUG
logger.debug { "User data: #{expensive_computation}" }
Rotate your logs. Without rotation, disk space will eventually fill up. Use size or time-based rotation from day one.
Do not log sensitive data. Passwords, tokens, and personal information should never appear in logs.
Use a structured format in production. JSON is easy to parse with log aggregation tools:
logger.formatter = proc do |severity, datetime, _progname, msg|
{ severity: severity, time: datetime.iso8601, message: msg.to_s }.to_json + "\n"
end
logger.info("User signed in")
# => {"severity":"INFO","time":"2026-03-31T10:00:00Z","message":"User signed in"}\n
Close your loggers. An unclosed file handle can prevent log rotation or cause data loss on shutdown. The log device buffers writes internally, and only a proper close guarantees that every message reaches disk safely before the process exits:
logger = Logger.new("app.log")
logger.info("Done")
logger.close
Put together, these choices give you a logging setup that is easy to reason about. You choose the destination, set the level, format the output, and rotate the files before they become a problem. That is enough for most Ruby apps, and it keeps Logger useful without hiding the simple shape of the API.
See Also
- /reference/kernel-methods/warn/: Kernel#warn, a lightweight alternative to Logger
- /tutorials/ruby-fundamentals/ruby-error-handling/: How to handle and raise exceptions in Ruby
- /guides/ruby-erb-templates/: Using ERB templates in Ruby for dynamic content
- /guides/ruby-debugging-with-debug-gem/: Debugging Ruby applications with the debug gem