Flask’s documentation says the Flask logger is a standard Python logger named after the app, and that is accurate. What it leaves to you is the production part: with debug mode off, INFO lines stay hidden below Python’s usual WARNING default, plain text resists filtering, no line carries a request id, and a log file written inside a container goes when the container is destroyed.
The Flask logger: what app.logger is, and the three settings to change first
The Flask logger is app.logger, a standard Python logger that takes the app’s name. With debug mode off, its INFO and DEBUG lines stay invisible, because Python’s default level is usually WARNING. Change three settings first: configure logging with dictConfig before creating the app, set the level to INFO, and send JSON lines to stdout.
The level you pick is the switch that decides which lines exist at all, and what each level should mean is a separate topic: logs severity levels. My working rule for a small web app is INFO at the root in production: your own INFO events stay visible, and DEBUG stays off.
Flask logging is standard Python logging. Flask’s logging documentation says messages about your application “are logged with app.logger, which takes the same name as app.name”, and the same logger “can also be used to log your own messages”. If app.logger is accessed before logging is configured, Flask adds a default handler; during a request it writes to the stream the WSGI server gives it in environ['wsgi.errors'], which the docs say is usually sys.stderr. Then the sentence the whole problem rests on: “If you don’t configure logging, Python’s default log level is usually ‘warning’. Nothing below the configured level will be visible.”
Debug mode hides that at first. Flask’s own source sets app.logger to DEBUG when debug mode is on and no level has been set, so INFO lines show during development. With debug mode off, the logger has no level of its own and falls back to the root logger, which the Python docs say “is created with level WARNING”. Python’s logging HOWTO puts the same default plainly: “The default level is WARNING, which means that only events of this severity and higher will be tracked, unless the logging package is configured to do otherwise.” The same app.logger.info() call that printed in debug mode then prints nothing once debug mode is off and logging is left unconfigured.
The three settings, in order. First, configure logging with dictConfig at the top of the module that creates the app, which Flask’s docs advise in so many words: “If possible, configure logging before creating the application object.” Second, set the root level to INFO (my working rule, above). Third, swap the plain-text formatter for a JSON one on a handler that writes to stdout. The block below does all three with the standard library only.
# log_setup.py
import json, logging
from contextvars import ContextVar
from datetime import datetime, timezone
request_id = ContextVar("request_id", default=None)
class JsonFormatter(logging.Formatter):
def format(self, record):
ts = datetime.fromtimestamp(record.created, tz=timezone.utc)
event = {"ts": ts.isoformat(timespec="milliseconds"), "level": record.levelname,
"logger": record.name, "msg": record.getMessage(),
"request_id": request_id.get(), **getattr(record, "fields", {})}
if record.exc_info:
event["exc"] = self.formatException(record.exc_info)
return json.dumps(event)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {"json": {"class": "log_setup.JsonFormatter"}},
"handlers": {"stdout": {"class": "logging.StreamHandler",
"stream": "ext://sys.stdout", "formatter": "json"}},
"root": {"level": "INFO", "handlers": ["stdout"]},
}
# app.py
from logging.config import dictConfig
from log_setup import LOGGING
dictConfig(LOGGING) # before the app object exists
from flask import Flask
app = Flask(__name__)
The blocks follow the Python 3.14 documentation and Flask’s 3.1.x documentation, read on 28 September 2026. The schema keys are the ones dictConfig documents: version (the only valid value at present is 1), formatters, handlers and root, and the ext:// prefix turns the string ext://sys.stdout into the real stream. disable_existing_loggers is set to False on purpose: when it is absent it defaults to True, and any existing non-root logger is disabled. Because the handler sits on the root logger, the lines from other libraries that log through the logging module reach it too, which is the route Flask’s docs call simplest: “add handlers to the root logger instead of only the app logger”. The request id and the fields key come into play in the request log section below.
One more line you may know from development: “Werkzeug logs basic request/response information to the ‘werkzeug’ logger.” Werkzeug writes those lines from its development server, which its own source describes as “for use during development only”. Gunicorn does not run that server, so under Gunicorn, in my reading, those lines do not appear, and they are never your request log. Flask logs in production are whatever the process writes to stdout and stderr, read through your host’s log view.
This page is one part of logging and monitoring, the overview of what to record and what to alert on.
Why it matters for a small Python app: a line nobody can find is not a log
In the third-party apps I audited, 17 of the 21 had no error tracking or alerting: when a user hits an error, nothing records it. Those 21 are the 11 public and the 10 held-out third-party apps I audited in June and July 2026, a selected set of audited apps rather than a random sample, so the count says nothing about Python apps as a whole.
When a backend’s logging is print() calls and library defaults, three things go wrong. The line was never emitted, because the level filtered it out. The line was emitted and cannot be found: plain text that a search cannot filter by field, no request id to tie it to the other lines of the same request, and a local-time timestamp that will not sort against another service’s. Or the line was found and should not exist, because it carries a token, an email address or a full request body.
This page fixes the first two for Flask and FastAPI. The shape every event should have and what to strip from it before it leaves the process are covered in error logging best practices, in its sections on giving every event a stable shape and on removing sensitive data. The language-neutral version of this setup, one event shape and correlation ids across a stack, is in how to do logging. The same job in a Node backend is a separate topic: pino logging.
How it works: from the logging call to a line someone can search
A log line travels a fixed path. Your code calls a logger, which creates a record. The handlers attached to that logger and its parents decide where the record goes, and each handler’s formatter decides what the line looks like. The process writes the line to a stream or a file. In a container, the runtime captures what the process writes: Docker’s docs say that “by default, docker logs shows the command’s STDOUT and STDERR”. From there the host, or a log tool you connect, stores the line and lets you search it.
The behavior in this section comes from the Python, Flask, Uvicorn, Gunicorn and Docker documentation and from the Flask and Uvicorn source, not from a benchmark or a test of mine. The handler table is my reading of that documented behavior: the facts in each cell carry their source, and the “right” and “wrong” judgments are mine.
| Destination | When it is right | When it is wrong |
|---|---|---|
stdout (StreamHandler(sys.stdout)) | A container or platform that collects process output; Docker’s docker logs shows STDOUT and STDERR by default | A long-lived server where nothing collects the process’s output |
stderr (StreamHandler() with no argument, which uses sys.stderr) | Command-line tools, where diagnostics must stay out of the real output | A web app that already writes some lines to stdout: Uvicorn’s default config sends uvicorn.error to stderr and uvicorn.access to stdout, so one request’s lines land in two streams |
A rotating file (RotatingFileHandler) | One process on a VPS where a person reads the files | A container, where data in the writable layer doesn’t persist when the container is destroyed; several Gunicorn workers writing one file, which the logging cookbook says is not supported |
syslog (SysLogHandler) | A VPS whose log shipping already runs through the local syslog daemon | A container platform that already collects stdout, where it adds a hop |
FastAPI logging: Uvicorn has its own loggers, and your app needs one too
FastAPI logging comes from two places: the server’s loggers and your app’s. Uvicorn provides three named loggers, uvicorn, uvicorn.error and uvicorn.access, and accepts a logging config. Create your module loggers with getLogger(__name__) and hand Uvicorn the same config, so the server’s lines and yours come out in one format.
Uvicorn’s docs describe the three loggers as a parent logger, “Server-level messages (startup, shutdown, errors)” and “Per-request access log lines”, and add that “despite its name, uvicorn.error is not limited to error messages”. Uvicorn’s logging settings list the switches: --log-config takes a logging configuration file, --log-level defaults to ‘info’, and --no-access-log will “Disable access log only, without changing log level”. When you start the server from code, the same config goes in as the log_config argument of uvicorn.run(), and a dictionary works there. Logging in FastAPI then needs nothing framework-specific: your routes use logging.getLogger(__name__), and the root handler from the first block formats their lines and Uvicorn’s alike.
# serve.py
import uvicorn
from log_setup import LOGGING
from main import app # your FastAPI app
if __name__ == "__main__":
uvicorn.run(app, log_config=LOGGING)
The block follows Uvicorn’s 0.54.0 documentation, read on 28 September 2026. From the command line, save the same dictionary as logging.json and pass --log-config logging.json; the class names in it are strings, so the dictionary converts to JSON as it is. Uvicorn’s default access line prints the client address and the request line. That line carries the URL the client asked for, as the Uvicorn advisory in the parser section below also shows, so if your URLs can carry a token or an email address in the query string, turn the access log off. To write your own request line instead, the one in the request log section below, set access_log=False or pass --no-access-log, which removes the handlers from uvicorn.access without touching uvicorn.error.
Under Gunicorn, Uvicorn’s deployment page gives gunicorn -k uvicorn.workers.UvicornWorker for production, and warns that the uvicorn.workers module “is deprecated and will be removed in a future release”, pointing to the separate uvicorn-worker package instead. Gunicorn’s own error and access lines follow its logconfig_dict setting, which takes “the standard Python logging module’s dictionary configuration format”. Keep the dictConfig call at the top of your app module either way, so your loggers are set whichever server imports it.
Python log to stdout: the StreamHandler default is stderr
Python log to stdout means passing sys.stdout to the handler, because a StreamHandler created with no argument writes to stderr. A containerized web app should pick one stream and keep it: Docker captures both, so mixing them only splits one request’s lines in two. Set PYTHONUNBUFFERED so lines are not held in a buffer.
The Twelve-Factor App’s logs factor puts the rule in one line: “each running process writes its event stream, unbuffered, to stdout”, and in staging or production that stream “will be captured by the execution environment”. Docker’s logging overview is the container half of that: the logs you read are what the command wrote to STDOUT and STDERR. The handlers reference says a StreamHandler given no stream uses sys.stderr, so a logger in Python reaches stdout only through a handler you point there: StreamHandler(sys.stdout) in code, or "stream": "ext://sys.stdout" in dictConfig, as the first block does. In a short script, logging.basicConfig(stream=sys.stdout, level=logging.INFO) adds a StreamHandler on that stream to the root logger and sets the root level in one call; it “does nothing if the root logger already has handlers configured”.
Buffering is the second trap. The Python docs say stdout is line-buffered when interactive and “otherwise, it is block-buffered like regular text files”, while stderr is line-buffered in both cases. The -u option will “Force the stdout and stderr streams to be unbuffered”, and setting PYTHONUNBUFFERED to any non-empty string does the same. My reading of those two lines: a print() in a container can show up late, and whatever sits in the buffer when the container is killed may never show up. Where Docker keeps those lines on the host and how they fill a disk is in how to clear Docker logs.
Write to stderr in Python: print with file=sys.stderr, or sys.stderr.write
Writing to stderr in Python takes one argument: print(..., file=sys.stderr), or sys.stderr.write() with its own newline. Scripts and command-line tools send errors to stderr so they stay out of the real output on stdout. A web app’s logs go through the logging module instead, to whichever stream its handler names.
To print to stderr in Python 3, pass file=sys.stderr to print(); print()‘s file argument falls back to sys.stdout when it “is not present or None”. Python’s stderr stream is where “the interpreter’s own prompts and its error messages go”, so the stream is meant for diagnostics rather than output. In Python, write to stderr without print() by calling sys.stderr.write(), which writes the string you give it and returns the number of characters written, so the newline is yours to add.
The reason to use stderr instead of stdout is separation: when a script’s output is piped into another program, errors on stderr stay on screen instead of flowing into that program’s input (my reading). In a web app, a logger beats a bare print to stderr for the reasons in the first FAQ answer below.
Python log to console and file with two handlers
Python logs reach the console and a file through two handlers on one logger: a StreamHandler and a RotatingFileHandler with a size limit and a backup count. That suits a long-lived server where someone reads the files. In a container the file sits in a layer that goes when the container is destroyed, so log to the stream only.
A Python logger to file and console needs nothing beyond the standard library:
import logging, sys
from logging.handlers import RotatingFileHandler
formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
console = logging.StreamHandler(sys.stdout)
to_file = RotatingFileHandler("app.log", maxBytes=10 * 1024 * 1024, backupCount=5)
root = logging.getLogger()
for handler in (console, to_file):
handler.setFormatter(formatter)
root.addHandler(handler)
root.setLevel(logging.INFO)
The size and the count are example values. Python’s logging handlers reference only warns that “if either of maxBytes or backupCount is zero, rollover never occurs”, so set a non-zero size and keep at least one backup. In a container, Docker’s storage docs are blunt: “Data written to the container layer doesn’t persist when the container is destroyed.”
Python logging to file and console gets harder with several worker processes. The logging cookbook says that “logging to a single file from multiple processes is not supported, because there is no standard way to serialize access to a single file across multiple processes in Python”. Its alternatives are to have every process log to a SocketHandler read by a separate listener process, or to send events through a QueueHandler to one process that writes them. This hits Gunicorn directly: its workers setting is “the number of worker processes for handling requests”, and the cookbook’s own section on Gunicorn and uWSGI says to “avoid creating file-based handlers directly in your web application”.
The Python logger timestamp: UTC, ISO 8601, with milliseconds
The Python logger timestamp comes from asctime, which uses local time by default and puts milliseconds after a comma. Production logs need one time zone in every service: set the formatter’s converter to time.gmtime, or emit an ISO 8601 UTC timestamp from a JSON formatter, so lines from two services sort in order.
The default Python logging timestamp format is '%Y-%m-%d %H:%M:%S,uuu', “where the uuu part is a millisecond value”, and the conversion uses time.localtime() unless you set the formatter’s converter attribute. Set it on one formatter instance, or on the Formatter class itself to change every formatter at once, which the docs suggest “if you want all logging times to be shown in GMT”: logging.Formatter.converter = time.gmtime.
The JSON formatter in the first block skips asctime and builds its own value: datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(timespec="milliseconds"), an ISO 8601 string cut to milliseconds that ends in the UTC offset +00:00. If your platform adds its own timestamp, keep yours as well: yours records when the event happened, the platform’s when the line was collected (my reading).
Python color log output: for your terminal only
A Python color log is ANSI escape codes added by a formatter or a handler such as Rich’s. The codes help in a local terminal. In a log store they arrive as stray characters that clutter search and, wrapped around a JSON line, stop it parsing, so turn color on in development and keep the JSON formatter everywhere else.
Two documented ways to add it: Rich’s logging handler, which Rich describes as “a logging handler which will format and colorize text written by Python’s logging module”, and colorlog’s ColoredFormatter, which its GitHub README says “extends logging.Formatter” and whose tagline is “Add colours to the output of Python’s logging module”. Uvicorn has its own switch, --use-colors or --no-use-colors, and its settings page notes that “this option is ignored if the --log-config CLI option is used”.
Python log color is a development setting, so choose the formatter from an environment variable at startup: a color console formatter when the app runs on your machine, the JSON formatter in staging and production. The terminal is the only reader that understands the codes.
How can I use a JSON logger in Python? A formatter, structlog or Loguru
A JSON logger in Python writes one object per line with the same keys in every service. There are three routes: a JSON formatter on the standard library, structlog with its processors and bound context, or Loguru’s single ready-made logger. django-structlog repeats a request id on every log line of a Django request.
The formatter route is the first block on this page: no new dependency, and every library that already logs through the standard library comes out in the same format. Which keys belong in the object is covered by the error logging best practices article linked above; here the job is only to make every line one parseable object.
structlog’s documentation describes its pieces in its own words. “A processor is a function that receives the event dictionary along with two other arguments and returns a new event dictionary”; values you pre-build “are called the context”, managed by creating new loggers “using bind() and unbind()”; and for JSON “you just have to tell it to use its JSONRenderer”. django-structlog is “a structured logging integration for Django project using structlog”, and its docs list request_id, correlation_id, user_id and ip as metadata “repeated on each log of the current request”.
Loguru takes the opposite approach: “there is one and only one logger”, and “it is pre-configured and outputs to stderr to begin with”. You add sinks with logger.add(), and serialize=True converts each message and its record “to a JSON string before being sent to the sink”. To set the log level in Loguru, you use that same call: the level argument on logger.add() is “the minimum severity level from which logged messages should be sent to the sink”, and it defaults to ‘DEBUG’. The Loguru levels in its docs are TRACE 5, DEBUG 10, INFO 20, SUCCESS 25, WARNING 30, ERROR 40 and CRITICAL 50; TRACE and SUCCESS are not in the standard library’s level table. The source is Loguru on GitHub, with the full API on Loguru’s docs site.
| Library | What it adds over the standard library | The catch (my reading) |
|---|---|---|
A JSON formatter on logging | Nothing to install; one class and one dictConfig | You keep the key list the same across services yourself |
| structlog | Processors, context bound with bind(), a JSONRenderer | Standard-library records need its ProcessorFormatter, “a logging.Formatter that enables you to format non-structlog log entries using structlog renderers” |
| Loguru | One pre-configured logger, sinks added with logger.add(), serialize=True for JSON | It starts on stderr; diagnose defaults to True and “should be set to False in production to avoid leaking sensitive data”; standard-library records need the README’s InterceptHandler recipe |
The catch with either second library is the same: Flask, Uvicorn and Gunicorn still log through the standard library, and other packages may too, so their records have to be routed into the library you chose, or half your lines come out in another format.
The Python request log: one line per request, with a request id
A Python request log is one line per request, written by your code after the response: method, route, status, duration, request id and user id. Bind the request id with contextvars so every other line in that request carries it, and never log the query string, body, cookies or Authorization header.
Take the id from the request id header your platform or proxy sets, if it sets one (the header name varies, so check your host’s docs), or generate one. Reusing the platform’s id means your lines and the platform’s own request log can be joined. The first block already declares the ContextVar at module level, which Python’s contextvars module insists on (“Context Variables should be created at the top module level and never in closures”), and its docs add that “context variables are natively supported in asyncio”. The JSON formatter reads the variable itself, so the id is added at the handler and reaches every line that handler writes, Uvicorn’s included. A filter attached to one logger would not: the logging docs note that records “generated by descendant loggers will not be filtered by a logger’s filter setting”.
import logging, time, uuid
from flask import g, request
from log_setup import request_id
log = logging.getLogger(__name__)
@app.before_request
def start_request():
g.start = time.perf_counter()
request_id.set(request.headers.get("X-Request-ID") or uuid.uuid4().hex)
@app.after_request
def log_request(response):
ms = round((time.perf_counter() - g.start) * 1000)
log.info("request", extra={"fields": {"method": request.method, "route": request.endpoint,
"status": response.status_code, "duration_ms": ms}})
response.headers["X-Request-ID"] = request_id.get()
return response
The pair uses Flask’s documented hooks: before_request registers “a function to run before each request”, and an after_request function “is called with the response object, and must return a response object”. request.endpoint is “the endpoint that matched the request URL”, a route name rather than the raw path, so ids and tokens in URLs stay out of the line. The extra argument puts the fields on the record, where the formatter picks them up. Add the user’s id to fields once your auth code knows it. In FastAPI the same two steps go in a function decorated with @app.middleware("http"), around the call_next(request) call. Set the id there, in the async middleware: structlog’s docs warn that in apps “based on Starlette (this includes FastAPI)”, context variables set in a synchronous context “don’t appear in logs from an async context and vice versa”.
The never-log list in the capsule covers this one line only; the full redaction rules stay with the error logging best practices article. Following one id from this service into the next one is the job of a trace id.
Python syslog: what SysLogHandler does, and when a small app needs it
Python syslog support is SysLogHandler: records go to a syslog daemon over a Unix socket, UDP or TCP. A small app needs it when a server already ships logs through rsyslog or journald. In a container it adds a hop for nothing, and over UDP with no daemon listening it may appear not to work: prefer the local socket.
The Python SysLogHandler defaults, from the handlers reference: with no address, it sends to ('localhost', 514); a string address such as '/dev/log' makes it use “a Unix domain socket”; facility defaults to LOG_USER; and socktype “defaults to socket.SOCK_DGRAM and thus opens a UDP socket”, with socket.SOCK_STREAM for TCP “for use with the newer syslog daemons such as rsyslog”. The same page warns that “if your server is not listening on UDP port 514, SysLogHandler may appear not to work”, and that on Linux the socket address is “usually ‘/dev/log’”.
Syslog Python code can also call the standard library’s separate module. Python’s syslog module “provides an interface to the Unix syslog library routines”, its availability line reads “Unix, not WASI, not iOS”, and its own docs point to SysLogHandler as the pure Python way to reach a syslog server. Python logging to syslog through the handler keeps your formatter and levels, so it is the one to use from a web app.
My reading of when a small app needs any of this: a VPS whose log shipping already runs on rsyslog or journald, or a customer environment that requires syslog. On a platform that collects stdout, write JSON to stdout and skip the extra hop. On AWS the collection path is different again, a separate topic: AWS logging and monitoring.
A Python log parser: reading the lines back
A Python log parser for JSON lines is json.loads on each line, or jq in a shell. Regular expressions are for legacy plain-text lines, and changing the format is the better fix. Treat every line as untrusted input: anything a request can set, such as its path, can end up in the log.
import json, sys
from collections import Counter
errors = Counter()
for line in sys.stdin:
event = json.loads(line)
if event.get("status", 0) >= 500:
errors[event["route"]] += 1
print(errors.most_common())
Save some output to a file and pipe it in; the script counts server errors by route from the request lines. json.loads raises a JSONDecodeError when “the data being deserialized is not a valid JSON document”, so the same script stops at the first line that breaks the format. In a shell, jq does the same job: its manual calls every jq program “a filter”, and jq 'select(.level == "ERROR")' passes through only the error lines. A log parser in Python built on regular expressions is only worth writing for old plain-text lines you cannot change, and never run eval on a log line.
Uvicorn shows why a log line is untrusted input. The GitHub Advisory Database entry for CVE-2020-7694, “Log injection in uvicorn”, says Uvicorn’s request logger logs the URL after processing it with urllib.parse.unquote, turning percent-encoded characters into single characters “which can have special meaning in terminal emulators”. By requesting crafted URLs, attackers can pollute the access logs and use ANSI sequence codes to attempt to interact with the terminal displaying them; the entry lists versions before 0.11.7 as affected and 0.11.7 as the first patched version. The full text is in the GitHub advisory for the Uvicorn log injection.
The lesson I take from it: a log line holds text an outsider chose, so read logs through a parser rather than dumping them raw into a terminal, keep the server that writes them patched, and let the formatter escape what it writes. json.dumps does that by default: its output is “guaranteed to have all incoming non-ASCII and non-printable characters escaped”.
How to check your own app: six things one test request should prove
A Python logging setup is proven by one test request and six checks: an INFO line from your code appears, every line parses as JSON, the request’s lines share an id that matches the response header, timestamps are UTC, a fake token and email sent in the request appear nowhere, and a forced error writes one ERROR line.
Run the checks on a local container or a staging copy started with production settings: debug mode off, Flask under Gunicorn or FastAPI under Uvicorn, started with the same command production uses. Never force an error on the live app. Start in the browser: open your host’s log view in one tab, load one page of the app in another, and find that request’s line.
- 01 An INFO line from your own code appears. Evidence: the line itself, with its timestamp.
- 02 Every line is valid JSON. Save about a minute of output and run the parser script from the section above over it; it stops on the first line that is not JSON, which is how a setup fails when Gunicorn or Uvicorn still prints plain text. Evidence: the script finishing without an error.
- 03 The lines of one request share one request id, and the X-Request-ID response header shows the same id. Evidence: the header value and the matching lines.
- 04 Timestamps are UTC and in order. Evidence: the UTC offset at the end of each timestamp, and no line earlier than the one before it.
- 05 Send a request with an obviously fake token in the Authorization header and a fake email address in the body, wait until that request line shows in the log view, then search the same output for both strings. Evidence: the request line found and both searches empty; a build that logs headers or bodies fails here.
- 06 In the staging copy only, call a route that raises, sending your own X-Request-ID header. Evidence: one ERROR line with its stack trace, carrying that id.
About a minute of normal traffic is my working rule for check 2: enough to include startup lines, request lines and the server’s own lines. The formatter in the first block ends every timestamp in +00:00, which is what check 4 looks for. In check 6, Flask logs the exception through app.logger as “Exception on” the path and method, and Uvicorn logs it through uvicorn.error as “Exception in ASGI application”. The id reaches that line because it is added at the handler; a filter on the root logger alone would miss it, for the reason given in the request log section. Keep the six outputs with the date you ran them.
Where the sprint fits
Deliverable 8.1 of the Production Hardening Sprint is Structured, sanitized logs, and the published scope describes it as “Add structured request logs with correlation IDs and appropriate user references; exclude passwords, tokens, and unnecessary personal data”. Deliverable 8.1 is verified this way: “Trace a test request across services and check log content for sensitive fields.” Deliverable 6.3, Error tracking, is verified this way: “Send a test error and verify symbolication, environment attribution, and alert delivery.” We refactor or replace components where the production work requires it, and your app’s current framework and hosting setup are our starting point. Hosting, paid tools and API usage remain in your accounts.
Common questions about Python web app logging
Why logger instead of print?
A logger gives every line a level you can switch off, handlers that choose where it goes, and a format that adds a timestamp and a request id, and other libraries in your app may already log through the logging module. print() has none of that, and its output on stdout is block-buffered when stdout is not interactive, as in a container.
What is logging getLogger (__ name __)?
logging.getLogger(__name__) returns a logger named after the current module. The Python logging HOWTO calls it “a good convention”, because “logger names track the package/module hierarchy, and it’s intuitively obvious where events are logged just from the logger name”. The same naming lets you raise or lower the level for one package without touching the rest.
Is Python logging thread safe?
Yes within one process: the docs say “the logging module is intended to be thread-safe without any special work needing to be done by its clients”, using locks around shared data and each handler’s output. Across processes it is a different answer: logging to one file from several processes “is not supported”, which covers Gunicorn’s worker processes, so log to stdout or a single listener instead.
What is the best logging module for Python?
The standard library’s logging module is the base, because Flask, Uvicorn and Gunicorn already log through it. structlog and Loguru are layers on top, chosen for how they feel to write with; neither is required, and a JSON formatter on logging covers a small web app.
What are the best practices for logging in Python?
One logger per module via getLogger(__name__), configured once at startup; INFO in production as my working rule; one JSON line per event on stdout; UTC timestamps; a request id on every line; no secrets, tokens or full request bodies in any line. Then prove it with one test request, using the six checks in the section on how to check your own app.
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase