1.Loggers, handlers, formatters, filters — and levels
Django'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.
Key features
- Four components: a logger is "a named bucket to which messages can be written", a handler "determines what happens to each message", a filter gives "additional control over which log records are passed from logger to handler", and a formatter describes "the exact format of that text"
- Levels: DEBUG (low-level detail), INFO (general), WARNING (a minor problem), ERROR (a major problem), CRITICAL (a critical problem)
- A record is filtered at four points: the logger's level, the logger's filters, each handler's level, and each handler's filters
- Records propagate up the dotted name hierarchy; `propagate: False` stops it, and its absence is the usual cause of duplicate lines
- Django merges your `LOGGING` with its defaults — `disable_existing_loggers: True` is how people accidentally silence `django.request`
- Filters can *enrich*, not only reject: returning `True` after setting an attribute is how a request id reaches every record
Django's own loggers, and what each gives you free
- 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.
Command
log = logging.getLogger(__name__) # "orders.services" — reaches "orders" and root
log.warning("stock low", extra={"sku": sku, "remaining": remaining})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`.