Loggers, handlers, formatters, filters — and levels
coreintermediateDjango's `LOGGING` setting has four kinds of moving part, and knowing which one to reach for removes most of the confusion. A **logger** is the named bucket you write to — `logging.getLogger(__name__)`. A **handler** decides where a record goes: the console, a file, an email. A **formatter** turns the record into text. A **filter** decides whether a record continues, and can also add fields to it. Records travel up the name hierarchy, so `orders.services` also reaches `orders` and the root — which is why one misplaced handler can double every line you emit.
Think of it as
Read the pipeline in order and most surprises explain themselves. You log to a logger; the logger's own level filters first; then filters attached to the logger run; then the record is passed to each of that logger's handlers, which apply their *own* level and their own filters; and finally a formatter renders it. Two levels and two filter stages mean a record can be dropped in four different places, and "my log line does not appear" is almost always one of them rather than a broken configuration. Then there is propagation. Logger names are dotted paths and records travel upward by default, so a record from `orders.services` is also offered to `orders` and to the root logger. That is a good default — it lets you configure handlers once at the root — and it is exactly what produces duplicate lines when someone attaches a handler to a child logger as well. `propagate: False` stops the climb, and it belongs on the specific loggers you have deliberately configured. The last piece is that Django merges your `LOGGING` with its own defaults rather than replacing them, and `disable_existing_loggers` controls whether the loggers already created keep working. Turning it on is a common way to silently lose `django.request` — the logger that reports every 5XX — which is usually the last thing you want gone from production.
What we're doing: A production `LOGGING` block that keeps Django's own loggers, adds context, and emits no duplicates.
- 4
- The one-word setting that costs people their 5XX logging. Django merges this configuration with its defaults; disabling existing loggers throws away the ones it already created.
- 12–13
- A filter used for enrichment. It returns `True` for every record but sets an attribute first, which is how `request_id` reaches lines emitted by Django and by third-party libraries.
- 26
- `RequireDebugFalse` passes records "only when `settings.DEBUG` is `False`", which is what stops a developer's local error from emailing the admins.
- 30–34
- Two handlers for one logger — console for everything at WARNING and above, email for ERROR and above, since the handler applies its own level on top of the logger's.
- 39
- One entry for your own top-level package. Child loggers such as `orders.services` inherit it, so per-module configuration is rarely needed.
- 33–42
- `propagate: False` on each configured logger. Without it every record is also handled by root, and every line appears twice — the most common Django logging bug.
Why this works: Django's existing loggers keep working, one filter enriches everything without touching call sites, handler levels do the fine-grained selection, and propagation is stopped exactly where handlers were attached.
Adding a handler to a child logger and getting every line twice
Wrong
Better
What you see: Every log line appears twice, log volume and bill double, and searches return two copies of each result — which also makes count-based alerts read exactly twice as high as reality.
Why: Records climb the dotted name hierarchy by default: a record on `orders.services` is offered to `orders` and then to the root logger, and every handler along that path emits it. Attaching a handler to `orders` while root also has one therefore delivers the record twice. `propagate: False` stops the climb at the logger you configured. The rule that avoids the problem entirely is to attach handlers in one place — usually root — and use child loggers only to set levels.
- Whole: "django.request": {"handlers": ["console", "errors"], "level": "WARNING", "propagate": False}
- "django.request" — the bucket, by dotted name: Django writes here already: 5XX responses at ERROR and 4XX at WARNING, with `status_code` and the `request` attached to the record. You are configuring an existing logger, not creating one.
- "handlers" — where records go from here: A list, because one record can go to several destinations. Each handler then applies its own level and its own filters, independently of the ones below.
- ["console", "errors"] — the destinations by name: These must exist in the `handlers` dict. A typo here is silent — the record is simply never delivered anywhere.
- "level": "WARNING" — the first of two gates: Records below this never leave the logger. A handler set to DEBUG cannot recover them, which is why "I set the handler to DEBUG and see nothing" is so common.
- "propagate": False — stop climbing the hierarchy: Without this the record is also offered to the root logger, so a root handler emits it a second time. This single key is the usual cause of every line appearing twice.
Django's own loggers, and what each gives you free
Together
Remember: Four parts and four gates: a record passes the logger's level, the logger's filters, each handler's level, and each handler's filters — so a missing line is usually one of those, not a broken config. Records climb the dotted hierarchy, so attach handlers in one place (root) and set `propagate: False` on any logger you do give handlers to, or every line appears twice. Keep `disable_existing_loggers: False` so Django's own `django.request` survives, use filters to *add* fields rather than only to reject, and remember `django.db.backends` logs SQL only when `DEBUG` is `True`.
See also: json logs and context fields · request ids correlation ids and error tracking · infra settings

