Guide

Understanding Log Levels: When to Use Debug, Info, Warn, Error, and Fatal

Log levels control what gets recorded and what gets ignored. Misusing them buries critical errors in noise or hides debugging clues. Here's when to use each level correctly.

LogFlow TeamAugust 20, 20267 min read

Every logging framework supports log levels, but most codebases use them inconsistently. One developer logs HTTP requests at info, another at debug. Error handling that should be error ends up at warn. Fatal conditions are logged at error and the process keeps running.

The result: filtering by level becomes unreliable. An error filter returns 500 results, most of which are actually warnings. Real errors get buried.

This guide defines each level with concrete examples, so the entire team uses them the same way.

The Five Standard Levels

Most frameworks support five levels, ordered by severity:

DEBUG → INFO → WARN → ERROR → FATAL

Setting the log level to INFO means DEBUG messages are suppressed. Setting it to ERROR suppresses everything below ERROR. Production systems typically run at INFO; development at DEBUG.

DEBUG — "I'm investigating something specific"

Use debug for information that's only useful when actively debugging a specific issue. If nobody is investigating a problem, these logs should not exist in the output.

Good debug logs:

logger.debug('Cache lookup', { key: 'user:4821', hit: false, ttl: 300 })
logger.debug('SQL query', { query: 'SELECT * FROM orders WHERE id = ?', params: [9912], durationMs: 12 })
logger.debug('JWT payload decoded', { userId: 4821, exp: 1753012800 })

Bad debug logs (should be a different level):

// This is INFO — it's a meaningful state change
logger.debug('User signed up', { userId: 4821 })

// This is WARN — something unexpected happened but was handled
logger.debug('Retry attempt 2 of 3 for payment API')

Rule of thumb: If turning off debug logs would make it harder to run the application normally, it's not a debug log — it's info or higher.

Production setting: OFF. Debug logs should never run in production by default. Enable them temporarily via environment variable or dynamic log level when investigating a specific issue.

INFO — "Something meaningful happened"

Use info for events that represent meaningful state changes in the application. A human reading the info stream should understand what the application is doing without being overwhelmed.

Good info logs:

logger.info('Server started', { port: 3001, env: 'production' })
logger.info('Order created', { orderId: 'ORD-9912', userId: 4821, total: 149.99 })
logger.info('Deployment completed', { version: 'v2.4.1', durationSec: 34 })
logger.info('Cron job completed', { job: 'cleanup_sessions', deletedCount: 47, durationMs: 234 })

Bad info logs (too noisy):

// This is DEBUG — routine operation, not a state change
logger.info('Checking cache for key user:4821')

// This is DEBUG — every successful request is not meaningful
logger.info('GET /api/health 200 12ms')

// This is too frequent — generates thousands of lines per minute
logger.info('Processing message from queue', { messageId: '...' })

Rule of thumb: If an info log fires more than once per second during normal operation, it's probably debug-level noise.

Production setting: ON. This is the default production level.

WARN — "Something unexpected happened, but the system handled it"

Use warn for situations that are abnormal but not failures. The system recovered or used a fallback, but someone should investigate if these warnings become frequent.

Good warn logs:

logger.warn('Rate limit approaching', { currentRate: 85, limitPerMin: 100, service: 'stripe-api' })
logger.warn('Deprecated API endpoint called', { path: '/v1/legacy/users', clientIp: '203.0.113.42' })
logger.warn('Retry succeeded on attempt 3', { operation: 'send_email', provider: 'resend' })
logger.warn('Slow query detected', { query: 'SELECT ...', durationMs: 4500, threshold: 2000 })
logger.warn('Disk usage above 80%', { usedPercent: 83, mountPoint: '/var/data' })

Bad warn logs:

// This is ERROR — the operation failed, it wasn't "handled"
logger.warn('Failed to send email to user', { error: 'SMTP connection refused' })

// This is INFO — expected behavior, not a warning
logger.warn('User logged out')

// This is DEBUG — not actionable
logger.warn('Cache miss for key user:4821')

Rule of thumb: A warning means "this worked, but it shouldn't have been necessary" or "this is fine now, but will become a problem if it continues."

Alert strategy: Don't alert on individual warnings. Alert when warning rate exceeds a threshold — a spike in "slow query" warnings indicates a systemic problem.

ERROR — "An operation failed and couldn't be recovered"

Use error when the system could not complete a requested operation. Something broke. A user was affected. This needs investigation.

Good error logs:

logger.error('Payment processing failed', {
  orderId: 'ORD-9912',
  userId: 4821,
  provider: 'stripe',
  errorCode: 'card_declined',
  amount: 149.99
})
logger.error('Database connection lost', { host: 'db-primary', retries: 3, error: err.message })
logger.error('External API returned 500', { service: 'geocoding', url: '/v1/lookup', statusCode: 500 })

Bad error logs:

// This is WARN — the retry succeeded, the operation wasn't lost
logger.error('Request failed, retrying...', { attempt: 1, maxAttempts: 3 })

// This is FATAL — if the app can't start, it's worse than error
logger.error('Cannot bind to port 3001, address already in use')

// This is WARN — a 404 is expected behavior, not an error
logger.error('User not found', { userId: 999 })

Rule of thumb: If the user's request succeeded (even via fallback), it's not an error. If the user's request failed and they got a 5xx response, it's an error.

Alert strategy: Alert on error rate exceeding a percentage threshold (e.g., 2% of requests returning errors for a service). Individual error alerts are too noisy.

FATAL — "The process is about to crash"

Use fatal for unrecoverable conditions. The process cannot continue and will exit. This is the last log line before shutdown.

Good fatal logs:

logger.fatal('Cannot connect to database on startup', { host: 'db-primary', error: err.message })
logger.fatal('Required environment variable missing', { variable: 'DATABASE_URL' })
logger.fatal('Unrecoverable state: data corruption detected', { table: 'orders', rowId: 9912 })

Fatal should be rare. If the application logs fatal more than once per deploy cycle, either the conditions aren't truly fatal or the infrastructure has serious problems.

Alert strategy: Every fatal log should trigger an immediate alert. Any channel — Slack, Telegram, SMS, PagerDuty. If the process is crashing, someone needs to know now.

The Decision Flowchart

When deciding which level to use:

  1. Is the process going to crash?FATAL
  2. Did a user-facing operation fail?ERROR
  3. Did something unexpected happen, but the system handled it?WARN
  4. Did a meaningful state change occur?INFO
  5. Is this only useful for debugging a specific issue?DEBUG

Common Mistakes

Logging everything at INFO

The most common mistake. Teams log every HTTP request, every database query, every cache lookup at info level. Production generates millions of info lines per day, and finding real state changes requires searching through noise.

Fix: Successful HTTP requests and routine operations are debug. Reserve info for business events (user created, order placed, deployment completed).

Using ERROR for expected failures

A 404 "user not found" is not an error — it's normal application behavior. A card decline is not an error — it's an expected business outcome. Logging these at error creates alert fatigue and buries real errors.

Fix: Expected failures that are part of normal flow → info or warn. Unexpected failures that indicate broken code or infrastructure → error.

No WARN level at all

Many codebases jump straight from info to error with nothing in between. This loses the valuable "things are degrading but not broken yet" signal — slow queries, retry storms, approaching rate limits.

Fix: Use warn as the early warning system. A spike in warnings often predicts errors 10-30 minutes later.

Production Configuration

Environment Level Why
Development DEBUG Full visibility for debugging
Staging DEBUG Catch issues before production
Production INFO Meaningful events without noise
Production (debugging) DEBUG Temporarily enabled for a specific service

Support dynamic log level changes without restarting the process. Most frameworks support this via environment variable reload or API endpoint:

// Express endpoint to change log level at runtime
app.post('/admin/log-level', (req, res) => {
  const { level } = req.body
  logger.level = level
  res.json({ level: logger.level })
})

This lets the team enable debug logging for a specific service during an incident without redeploying.


Consistent log levels are the foundation of useful alerting. Without them, every alert threshold is a guess. With them, level:error means something is actually broken — and that's a filter worth alerting on.

Start monitoring your logs today

Free plan available. No credit card required. Up and running in 2 minutes.

Get started free