Logging in Ruby

· 5 min read · Updated March 31, 2026 · beginner
ruby logging stdlib logger guides

Logging in Ruby

Every Ruby application needs a way to record what it is doing. The standard library comes with Logger, a battle-tested class that handles log levels, formatting, and file rotation out of the box. This guide covers everything you need to know to get started.

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:

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 (append, write, or truncate):

# 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

Log Levels

Logger defines six log levels, from least to most severe:

LevelConstantWhen to use
DEBUGLogger::DEBUGDetailed diagnostic information
INFOLogger::INFOGeneral runtime events
WARNLogger::WARNSomething unexpected, but not fatal
ERRORLogger::ERRORA serious problem that prevented something
FATALLogger::FATALA crash is about to happen
UNKNOWNLogger::UNKNOWNAny message at all

The default level is INFO. Any messages below the current level are ignored.

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:

logger.level = Logger::WARN
logger.debug("Ignored")  # not printed
logger.warn("Shown")     # printed

Writing Log Messages

The primary methods map directly to log levels:

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 as the first argument:

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.

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.

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).

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:

logger.info("EmailService") { "Email sent" }

Custom Datetime Format

To change the timestamp format, pass a datetime_format argument to the constructor:

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:

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 Rotation with Logger::LogDevice

Pass a shift age and shift size to the constructor:

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.

Time-Based Rotation

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.

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:

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:

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.

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:

# 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:

logger = Logger.new("app.log")
logger.info("Done")
logger.close

See Also