Why Your FastAPI Logs Vanish Under Gunicorn and Uvicorn (and the Config That Works Everywhere)
Your FastAPI app logs perfectly on your laptop. You run uvicorn main:app --reload, hit an endpoint, and there are your logger.info() lines, right where you put them. You ship it in a Docker image behind gunicorn -k uvicorn.workers.UvicornWorker, and the logs are gone. Not all of them: the access logs and startup banner are there, but your application's own INFO and DEBUG lines have silently evaporated. Nobody changed a log level. Nothing errored.
This is one of the most reported and least satisfyingly answered problems in the FastAPI ecosystem. The GitHub thread on it ran to dozens of comments and closed without a clean answer. The reason it is so slippery is that nothing is broken. Python's logging did exactly what you configured, which is the problem: you configured it to disable your own loggers, and you did not know it.
your dictConfig almost certainly has disable_existing_loggers at its default of True, which silences every logger created before your config runs, including ones uvicorn already set up. Set it to False, and configure the uvicorn, uvicorn.error, uvicorn.access and (under gunicorn) gunicorn.error loggers explicitly, all pointing at the same handler. Configure logging once, at import time, before the app starts serving.
The symptom, precisely
There is no error message, which is exactly why it is hard. The tell is the asymmetry: some logs appear and some do not.
# In dev (uvicorn main:app): everything shows
INFO: Started server process [12345]
INFO: Application startup complete.
INFO: app.orders: created order 8821 <-- your log, visible
# In prod (gunicorn -k uvicorn.workers.UvicornWorker): your line is gone
[2026-07-24 09:02:11 +0000] [7] [INFO] Booting worker with pid: 7
INFO: Application startup complete.
# ...no "created order" line ever appears...
Or the other classic version: it works at DEBUG and disappears at INFO, or works for one module and not another. All of these are the same underlying issue wearing different clothes.
Why this happens: two traps stacked on top of each other
Python has a single global logging registry. When you call logging.getLogger("uvicorn.error"), you get back the one and only object with that name, no matter who asks. uvicorn and gunicorn both reach into that registry at startup and configure their own named loggers: uvicorn, uvicorn.error, uvicorn.access, and gunicorn.error. Here is the first trap:
Those loggers are configured independently and, by default, do not propagate their records up to the root logger.
So if your strategy is "attach one handler to the root logger and let everything bubble up", uvicorn's logs will never reach it, because uvicorn set propagate = False on its loggers. You are formatting a root nobody is talking to.
The second, nastier trap is in logging.config.dictConfig. It has a key called disable_existing_loggers, and its default value is True. When your config runs with that default, every logger object that already existed at that moment, and is not explicitly named in your config, gets disabled. Not reconfigured. Disabled. Its handlers stop emitting.
Now combine that with import order. Under gunicorn with uvicorn workers, the worker boots, uvicorn configures its loggers, and then your application module imports and calls dictConfig with the default True. Every logger created during that window, potentially including a module-level logger = logging.getLogger(__name__) in a file imported earlier, gets switched off. In plain uvicorn --reload dev the ordering is different, so the same code appears to work. Same config, different lifecycle, opposite result. That is why "it works locally" tells you nothing here.
The fix: one dictConfig that names every logger
Step 1: Write a config that disables nothing and names the uvicorn/gunicorn loggers
The core rules are: disable_existing_loggers: False, one handler and formatter, and an explicit entry for each logger uvicorn and gunicorn own so their records reach your handler and are not double-emitted.
import logging.config
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s %(levelname)s %(name)s: %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"stream": "ext://sys.stdout",
},
},
"root": {"handlers": ["console"], "level": "INFO"},
"loggers": {
# your app: let it propagate to root, do not add a second handler
"app": {"level": "INFO", "handlers": [], "propagate": True},
# uvicorn's loggers: hand them to the same console handler
"uvicorn": {"handlers": ["console"], "level": "INFO", "propagate": False},
"uvicorn.error": {"handlers": ["console"], "level": "INFO", "propagate": False},
"uvicorn.access": {"handlers": ["console"], "level": "INFO", "propagate": False},
# gunicorn's error logger, present only under the gunicorn worker
"gunicorn.error": {"handlers": ["console"], "level": "INFO", "propagate": False},
},
}
logging.config.dictConfig(LOGGING_CONFIG)
Note the asymmetry that trips people up: your app logger has propagate: True and no handler of its own, so its records travel up to the root handler once. The uvicorn loggers have their own handler and propagate: False, because they do not reach root on their own and you do not want them doubled. Attaching a handler and leaving propagate on is the usual cause of every line printing twice.
Step 2: Run the config before the app serves, not after
Ordering is the whole game. Put the dictConfig call in a module that is imported before your routes start handling traffic, or better, hand it to the server directly so it runs first:
# uvicorn: pass the dictConfig as a file, runs before your app imports
uvicorn main:app --log-config logging.yaml
# gunicorn: point at the same config, uvicorn worker inherits it
gunicorn main:app -k uvicorn.workers.UvicornWorker --log-config logging.ini
Using --log-config sidesteps the import-order trap entirely, because the server applies your config before your application code ever runs. If you prefer keeping it in Python, call dictConfig at the top of the module gunicorn imports first, so nothing your app creates is "existing" when it runs.
Step 3: Make sure Docker is not the one eating the output
Logs that clear Python can still vanish at the container boundary. Two things matter: log to stdout, not a file inside the container, and do not let Python buffer it.
ENV PYTHONUNBUFFERED=1
CMD ["gunicorn", "main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000", "--log-config", "logging.ini"]
Without PYTHONUNBUFFERED=1, short-lived containers can exit before Python flushes its buffer and you lose the last lines, which reads exactly like "logging is broken" when it is not.
Verification
Prove it end to end from inside the running container, at the level that was disappearing:
docker compose up -d
curl -s localhost:8000/health > /dev/null
docker compose logs --tail=20 web | grep "app."
You should see your INFO app.orders: ... line in the container logs, formatted by your formatter, exactly once. Then confirm you did not just move the problem by checking a deliberate DEBUG line and an ERROR line both appear when their logger is at the right level. If access logs and your app logs share one format, the config took.
What people get wrong
Bumping the root logger to DEBUG and calling it fixed. If the real issue is a disabled logger or a non-propagating uvicorn logger, cranking the root level changes nothing, because the record never reaches root in the first place. You end up with noisier third-party libraries and still no app logs.
Adding logging.basicConfig() "just in case". basicConfig is a no-op if any handler already exists on the root logger, which under uvicorn it does. So it silently does nothing, and people conclude logging is cursed. Configure once with dictConfig and delete the stray basicConfig.
Leaving disable_existing_loggers at its default. This is the actual root cause in most cases and it is the one setting nobody mentions, because the default is invisible. If you take one thing from this: disable_existing_loggers: False, always, in a server context.
When it is still broken
- List what is actually configured at runtime. Drop this into a startup event and read the output: it prints every logger, its level, whether it is disabled, and its handlers. It ends the guessing immediately.
import logging for name, lg in logging.root.manager.loggerDict.items(): if isinstance(lg, logging.Logger): print(name, lg.level, "disabled=" + str(lg.disabled), lg.handlers) - Check the handler level, not just the logger level. A logger at INFO with a handler at WARNING drops your INFO lines. Both have a level, and both must pass.
- Confirm you are reading the right stream. Some setups send access logs to stdout and errors to stderr. If your log driver only captures one, half your logs look missing. My note on what the uvicorn.error logger actually is explains why that named logger is noisier than its name suggests.
- Rule out an unrelated crash. If the worker is dying and being restarted, you might be seeing boot logs only. A QueuePool exhaustion under load or a failed import can look like missing logs when the real event is a restart loop. Cross-check with the container and journal logs.
Frequently asked questions
- Why do my FastAPI logs show in uvicorn dev but disappear under gunicorn?
- Because gunicorn and uvicorn each configure their own loggers, and the moment uvicorn sets up logging it can disable the handlers you attached earlier if your dictConfig uses disable_existing_loggers: True, which is the default. In dev you often import your config after uvicorn is already running, so it survives; under gunicorn the ordering flips and your handlers get silenced. Set disable_existing_loggers to False and configure logging explicitly for the uvicorn and gunicorn loggers.
- Why do DEBUG logs work but INFO logs disappear?
- That is almost always a level mismatch on a specific named logger or its handler, not the root logger. A handler set to WARNING drops everything below WARNING even if the logger itself is at INFO, and vice versa. Check the effective level of the exact logger you are calling and the level of the handler attached to it, because a log record has to clear both.
- Do I need to set propagate on my loggers?
- You need to be deliberate about it. uvicorn's loggers (uvicorn, uvicorn.error, uvicorn.access) do not propagate to the root logger by default, so a root handler will not catch them. Either attach handlers directly to those named loggers, or set propagate: True on them and let a single root handler format everything. Doing both attaches the handler twice and you get duplicate lines.
- Should I use --log-config with a file instead of dictConfig in code?
- Both work, but pick one source of truth. uvicorn accepts --log-config pointing at a YAML/JSON/INI dictConfig, which runs before your app imports and avoids the ordering trap entirely. If you configure logging in Python code instead, call logging.config.dictConfig at import time in a module that loads before the app starts handling requests, and always with disable_existing_loggers set to False.