Gunicorn and Uvicorn Worker Logging

Running Uvicorn workers under Gunicorn gives a Python ASGI service Gunicorn's process management and Uvicorn's event loop — and two servers' worth of logging configuration, applied at two different times, in two different processes. The typical result is JSON application logs interleaved with plain-text lines from both servers, access records duplicated, and a queue listener that works locally and silently drops everything in production. This page covers putting all of it into one JSON stream. It is a task article under logging in Python runtimes and frameworks, part of the modern Python logging libraries deep dive section.

Two servers, two processes, one stream A Gunicorn master process starts and applies its logging configuration to the gunicorn.error and gunicorn.access loggers. It then forks four worker processes. Each worker is a Uvicorn worker, which applies Uvicorn's default logging configuration to the uvicorn, uvicorn.error and uvicorn.access loggers when it starts, giving them plain-text handlers that do not propagate. The application is imported in each worker and logs through its own loggers to the root. Without intervention, a single request can produce a Gunicorn access record, a Uvicorn access record and application records, in two formats. With the fixes applied — a logconfig_dict for Gunicorn, Uvicorn's configuration disabled, server loggers propagating to one root handler, one access log chosen, and per-process resources started in post_fork — every record from every source is written once, by one JSON formatter, to standard output. who configures what, and when Gunicorn master gunicorn.error gunicorn.access configured at start fork Uvicorn worker × 4 uvicorn, uvicorn.error uvicorn.access — plain text defaults applied at worker start application its own loggers → root → JSON untreated: one request, three records, two formats 10.0.4.91 - - [18/Sep/2026:14:02:11] "POST /orders" 201 · INFO: 10.0.4.91 - "POST /orders" 201 {"levelname": "INFO", "name": "orders", "message": "order accepted"} treated: every source propagates to one root handler, one access log, one formatter per-process resources started in post_fork, so they exist where the records are made
Configuration happens in two processes at two moments. Getting one stream out of it means acting at both, and choosing which server writes the access log.

Prerequisites

pip install "gunicorn>=22.0.0,<24.0.0" \
            "uvicorn>=0.30.0,<1.0.0" \
            "python-json-logger>=2.0.7,<4.0.0"

Implementation

Step 1 — Give Gunicorn a logconfig_dict. Gunicorn applies it in the master at startup, before forking, so the master's own messages — worker boots, timeouts, signals — are formatted as JSON from the first line. The dictionary is an ordinary dictConfig and should name the server's loggers explicitly, with no handlers of their own and propagation enabled.

# gunicorn.conf.py
import os

bind = "0.0.0.0:8000"
workers = int(os.environ.get("WEB_CONCURRENCY", "4"))
worker_class = "myservice.workers.QuietUvicornWorker"
accesslog = None                     # 1. Uvicorn will write the access log instead

logconfig_dict = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "json": {"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
                 "fmt": "%(asctime)s %(levelname)s %(name)s %(message)s"},
    },
    "handlers": {
        "stdout": {"class": "logging.StreamHandler",
                   "stream": "ext://sys.stdout", "formatter": "json"},
    },
    "loggers": {
        "gunicorn.error":  {"handlers": [], "propagate": True, "level": "INFO"},
        "gunicorn.access": {"handlers": [], "propagate": True, "level": "INFO"},
        "uvicorn":         {"handlers": [], "propagate": True, "level": "INFO"},
        "uvicorn.error":   {"handlers": [], "propagate": True, "level": "INFO"},
        "uvicorn.access":  {"handlers": [], "propagate": True, "level": "INFO"},
    },
    "root": {"handlers": ["stdout"], "level": "INFO"},
}

Step 2 — Stop the workers applying Uvicorn's defaults. Each Uvicorn worker, when it starts, applies Uvicorn's own default logging configuration unless told otherwise — which gives the uvicorn loggers plain-text handlers again, undoing step 1 inside every worker. A small worker subclass that sets Uvicorn's log configuration to None leaves the loggers as the master configured them.

# myservice/workers.py
from uvicorn.workers import UvicornWorker

class QuietUvicornWorker(UvicornWorker):
    CONFIG_KWARGS = {"log_config": None}       # 2. do not apply Uvicorn's defaults

Step 3 — Confirm every server logger propagates to the one root handler. With steps 1 and 2 in place, records from gunicorn.*, uvicorn.* and the application all reach the root logger and are written by the same JSON formatter. The inspection script from logging in Python runtimes and frameworks shows it directly: no server logger should have a handler of its own.

Step 4 — Choose one access log. With Uvicorn workers, both servers can record the same request. Gunicorn's access log is written only when accesslog is set, so leaving it unset — as in step 1 — makes Uvicorn's the single access log. Uvicorn's records carry the ASGI details and are the more useful of the two.

Step 5 — Start per-process resources in post_fork. Anything owning a thread must be created in each worker. A queue listener started in the master exists only there; records enqueued in workers are never written. The post_fork hook runs in each worker immediately after it is created.

# gunicorn.conf.py (continued)
def post_fork(server, worker):
    from myservice.logsetup import start_queue_listener, start_telemetry
    start_queue_listener()             # 3. a thread in THIS worker
    start_telemetry()                  #    and the OpenTelemetry processors too
    worker.log.info("worker ready", extra={"worker_pid": worker.pid})

Expected Output: one stream, one format, one access record per request.

{"asctime": "2026-09-18 14:02:09,882", "levelname": "INFO", "name": "gunicorn.error", "message": "Booting worker with pid: 41"}
{"asctime": "2026-09-18 14:02:10,114", "levelname": "INFO", "name": "gunicorn.error", "message": "worker ready", "worker_pid": 41}
{"asctime": "2026-09-18 14:02:11,408", "levelname": "INFO", "name": "orders", "message": "order accepted"}
{"asctime": "2026-09-18 14:02:11,409", "levelname": "INFO", "name": "uvicorn.access", "message": "10.0.4.91:51022 - \"POST /orders HTTP/1.1\" 201"}

Step 6 — Structure the access record. Uvicorn's access message is a formatted string. A filter on uvicorn.access that copies the request's parts into fields — method, path, status — makes access records queryable like everything else, rather than requiring a regular expression over the message.

import logging

class AccessFields(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        # uvicorn passes (client, method, path, http_version, status) as args
        if record.name == "uvicorn.access" and isinstance(record.args, tuple) and len(record.args) == 5:
            client, method, path, _, status = record.args
            record.client = client
            record.http_method = method
            record.url_path = path.split("?", 1)[0]
            record.http_status = int(status)
        return True

logging.getLogger("uvicorn.access").addFilter(AccessFields())
Why post_fork matters Two arrangements of a queue-based logging setup under a prefork server. In the first, the queue and its listener thread are created when the application module is imported in the master, before forking. Each worker inherits a copy of the queue object and the QueueHandler that writes to it, but no listener thread, because threads do not survive a fork. Workers enqueue records into their own copies of the queue, which nothing ever drains; memory grows until the queue is full, after which records are dropped, and not a single worker record reaches the output. In the second, the listener is started in the post_fork hook, so each worker has its own queue and its own listener thread, and every record is written. The note records that the first arrangement works perfectly in local development, where there is usually no fork, which is why it reaches production. the same queue setup, started in two places started at import, in the master master: queue + listener worker: queue worker: queue worker: queue no listener workers enqueue into copies nothing drains — not one worker record is written started in post_fork, in each worker queue + listener queue + listener queue + listener all drained the first version works in local development, where nothing forks — which is how it reaches production
Threads do not cross a fork. A listener started before the fork leaves every worker with a queue and nobody reading it.

Timeouts, restarts and the records that explain them

The most important records from a Gunicorn deployment are often the ones about the workers themselves, and they need specific attention.

Worker timeouts. When a worker exceeds its timeout, the master kills it and logs a critical record naming the worker. The worker itself gets no chance to log anything, so the application's last records before the timeout are the only clue to what it was doing — which argues for logging at the start of long operations, not only at the end. The master's record, formatted as JSON by step 1, is what an alert on worker timeouts should match.

Worker restarts from max_requests. Recycling workers after a number of requests, as discussed in diagnosing RSS growth in Python containers, produces a regular stream of worker exits and boots. These are normal and should be logged at info, and it is worth confirming they are distinguishable from crashes — a worker exiting after its request limit and one exiting after an unhandled exception should not look the same in the logs.

Graceful shutdown. On a termination signal, the master asks workers to finish their current requests. Records emitted during that window are the ones describing the shutdown, and they are lost if the queue listener is not stopped cleanly. A worker_exit hook that stops the listener, and a graceful timeout long enough for it, closes that gap, in the same way as described in graceful shutdown and telemetry flush.

def worker_exit(server, worker):
    from myservice.logsetup import stop_queue_listener
    stop_queue_listener()              # drain the queue before the process ends

Running Uvicorn directly instead

Many deployments run Uvicorn on its own, with its own process management or with one process per container and the platform providing replication. The logging picture is simpler and the same principles apply.

Uvicorn accepts a logging configuration through its log_config option — a dictionary or a path to a file — and applies it at startup in place of its defaults. Passing the same dictionary used for Gunicorn above, with the uvicorn loggers propagating to a JSON root handler, produces the same unified stream. Passing None tells Uvicorn to leave logging alone, which is appropriate when the application configures logging itself before Uvicorn starts.

With --workers greater than one, Uvicorn forks worker processes much as Gunicorn does, and the post-fork concern returns: anything owning a thread must be created in the worker. Uvicorn has no equivalent of Gunicorn's hook, so the usual place is the application's startup event, which runs in each worker after it has started serving. A queue listener or telemetry processor created there exists in the right process.

The single-process-per-container arrangement is the simplest of all: no fork, one process, and a logging configuration applied at import works exactly as written. It trades Gunicorn's worker management — timeouts, graceful restarts, request limits — for that simplicity, and relies on the platform to restart failed containers. For services whose logging has been a recurring source of trouble, that trade is often worth considering in its own right.

Which process writes which record A table of log records under Gunicorn with Uvicorn workers and the process that writes each. Worker boot and exit messages come from the Gunicorn master. WORKER TIMEOUT and signal messages also come from the master, which is why they lack request context. Access log lines come from the worker, written by the Uvicorn access logger. Application records come from the worker, through whatever configuration the application applied after fork. Unhandled exception tracebacks come from the worker's Uvicorn error logger unless the application catches them. The note says the master's records need the same JSON formatter, configured through Gunicorn's logconfig_dict, or they arrive as plain text. record written by configured through worker boot and exit master logconfig_dict WORKER TIMEOUT master logconfig_dict access lines worker uvicorn.access logger application records worker app config after fork unhandled tracebacks worker uvicorn.error logger the master's records need the JSON formatter too, or they arrive as plain text
Two processes write to one stream. The master's records are the ones that explain restarts, and they bypass application config.

Configuration options

Setting Where Value Why
logconfig_dict gunicorn.conf.py full JSON config master formatted from the first line
Worker class gunicorn.conf.py Uvicorn worker with log_config=None no plain-text defaults in workers
Server logger handlers config none, propagate one formatter for everything
accesslog gunicorn.conf.py unset Uvicorn writes the single access log
post_fork gunicorn.conf.py start listeners and telemetry threads exist in each worker
worker_exit gunicorn.conf.py stop listener final records flushed
Access filter uvicorn.access method, path, status as fields queryable access records

Verification

Run the server, make one request, and check the output is uniform and not duplicated.

gunicorn -c gunicorn.conf.py myservice.app:app &
sleep 3 && curl -s -X POST localhost:8000/orders -d '{}' >/dev/null && sleep 1
kill %1
# every line JSON, exactly one access record for the request
python3 - <<'PY'
import json, sys
lines = open("/tmp/server.out").read().splitlines()
bad = [l for l in lines if not l.startswith("{")]
access = [l for l in lines if '"uvicorn.access"' in l or '"gunicorn.access"' in l]
print(f"non-JSON lines: {len(bad)}  access records: {len(access)}")
PY

Expected Output:

non-JSON lines: 0  access records: 1

Common mistakes

Uvicorn defaults re-applied in workers. Error signature: JSON from the master, plain text from the workers. Root cause: each Uvicorn worker applies its own logging configuration at start. Remediation: a worker class with log_config=None.

Two access logs. Error signature: every request logged twice in different formats. Root cause: both Gunicorn's and Uvicorn's access loggers enabled. Remediation: leave Gunicorn's accesslog unset.

Listener started at import. Error signature: no worker records at all in production. Root cause: the listener thread exists only in the master. Remediation: start it in post_fork.

No worker_exit flush. Error signature: shutdown records missing from every deploy. Root cause: the queue abandoned when the worker exits. Remediation: stop the listener in worker_exit.

Access records as strings only. Error signature: dashboards built on regular expressions over the access message. Root cause: Uvicorn's access message is a formatted string. Remediation: a filter that copies its arguments into fields.

Frequently Asked Questions

Why does every request appear twice in the access log?

With Uvicorn workers under Gunicorn, both servers can emit an access record for the same request — Gunicorn's from its access logger and Uvicorn's from uvicorn.access. Disable one of them. Uvicorn's has more detail about ASGI requests; Gunicorn's is disabled by leaving its access log unset.

Where should logging be configured, the master or the workers?

Both, differently. The formatter and handler configuration can be applied in the master and inherited, but anything owning a thread — a queue listener, an OpenTelemetry processor — must be created in each worker after the fork, in the post_fork hook.

Why is my Uvicorn access log plain text despite my JSON config?

Uvicorn installs its own default logging configuration when its workers start, which gives the uvicorn loggers their own handlers with a plain-text formatter. Either pass your configuration to Uvicorn or clear those handlers after it applies its defaults.

Can the master and workers share a log file?

They can share standard output safely, because each process writes whole lines below the atomicity limit. Sharing a file through separate file handlers in each process risks interleaved writes and breaks rotation. Write to standard output and let the platform collect.