At a glance
logging.basicConfig(filename="app.log", level=logging.INFO)appendsINFOand higher records to a file.handlers=[logging.FileHandler("train.log"), logging.StreamHandler()]writes to the file and to stderr at the same time.RotatingFileHandlerwithmaxBytesandbackupCount, orTimedRotatingFileHandlerwithwhen="midnight", caps disk use.logging_redirect_tqdm()fromtqdm.contrib.loggingkeeps console logs from breaking a progress bar.
Log to a file with basicConfig
The fastest way to replace print() calls with a log file is logging.basicConfig(). Pass filename, level, format and encoding once when your program starts; encoding was added in Python 3.9.
The root logger's default level is WARNING, so without level your info() and debug() lines are silently dropped. filemode defaults to 'a', so new runs append to the same file; pass filemode="w" to start fresh each run.
import logging
logging.basicConfig(
filename="app.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
encoding="utf-8",
)
log = logging.getLogger(__name__)
log.info("started")
log.warning("disk at %d%%", 91)2026-10-30 02:30:04,015 INFO __main__: started
2026-10-30 02:30:04,015 WARNING __main__: disk at 91%Without format, lines look like INFO:root:message. One catch comes up often in notebooks and larger projects: basicConfig() does nothing if the root logger already has handlers, which happens when a library or the notebook kernel configures logging first. Pass force=True (Python 3.8+) to remove and close the existing handlers before yours are added.
Log to a file and the console at once
For a training script or data pipeline, you usually want a file on disk plus live output in your terminal. Pass a list to handlers: one FileHandler for the file and one StreamHandler, which writes to sys.stderr when you give it no stream.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[
logging.FileHandler("train.log", encoding="utf-8"),
logging.StreamHandler(),
],
)
logging.info("epoch 1 done")Don't pass filename or stream together with handlers. Python doesn't merge them; it raises ValueError: 'stream' or 'filename' should not be specified together with 'handlers'. Put the path and encoding on the FileHandler instead.
Rotate log files by size or by time
A plain FileHandler grows until you delete the file or fill the disk. The handlers in logging.handlers rotate it for you:
RotatingFileHandlerrolls over when the file is about to exceedmaxBytesand keepsbackupCountold files named.1,.2and so on. If either value is0, it never rotates.TimedRotatingFileHandlerwithwhen="midnight"andbackupCount=7starts a new file at midnight, adds a%Y-%m-%dsuffix to the old one and keeps at most 7.WatchedFileHandlerreopens the file when an external tool such aslogrotatemoves it. It isn't meant for Windows.
import logging
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler("app.log", maxBytes=10_000_000, backupCount=5, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
log = logging.getLogger("etl")
log.setLevel(logging.INFO)
log.addHandler(handler)This keeps at most six files of about 10 MB each: app.log plus app.log.1 through app.log.5. For daily files, swap in TimedRotatingFileHandler("app.log", when="midnight", backupCount=7, encoding="utf-8").
| Handler | Module | When it starts a new file | Use it for |
|---|---|---|---|
FileHandler | logging | Never | Short jobs and one log per run |
RotatingFileHandler | logging.handlers | When the file reaches maxBytes | Long-running jobs with a disk cap |
TimedRotatingFileHandler | logging.handlers | On a schedule such as when="midnight" | One file per day or hour |
WatchedFileHandler | logging.handlers | When logrotate moves the file | Linux servers that already use logrotate |
Format strings that make logs useful
The default INFO:root:message format doesn't say when an event happened or where it came from. Add any LogRecord attribute to format and set the timestamp layout with datefmt, which uses time.strftime codes:
%(asctime)sfor the time,%(levelname)sfor the level and%(message)sfor the message.%(name)sfor the logger name.%(process)dand%(threadName)sfor the process ID and thread name.%(funcName)sand%(lineno)dfor the function and line number.
Create one logger per module with logging.getLogger(__name__), so %(name)s shows which module wrote each line. Pass variables as arguments (log.info("processed batch %d of %d", i, total)) instead of f-strings, so formatting only happens when the record is emitted.
logging.basicConfig(
filename="app.log",
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s %(funcName)s:%(lineno)d %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)2026-10-30 02:30:04 INFO __main__ run:13 processed batch 1 of 2Log inside tqdm loops without breaking the bar
If you log to the console while a tqdm bar is running, StreamHandler writes to sys.stderr over the bar and leaves broken fragments. Wrap the loop in logging_redirect_tqdm from tqdm.contrib.logging: console logging goes through tqdm.write, so each message prints cleanly above the bar, and file handlers keep writing as normal.
import logging
from tqdm import tqdm
from tqdm.contrib.logging import logging_redirect_tqdm
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger(__name__)
with logging_redirect_tqdm():
for step in tqdm(range(3), desc="steps"):
log.info("step %d loss=%.3f", step, 1 / (step + 1))A log file on a server records what already happened, but it doesn't show how far along a running job is unless you SSH in and tail it.
FAQ
Why is my log file empty?
Usually one of two defaults: the root logger's level is WARNING, so info() and debug() calls are dropped unless you pass level=logging.INFO, and basicConfig() does nothing if the root logger already has handlers. Pass level=logging.INFO and force=True.
How do I overwrite the log file on each run instead of appending?
basicConfig() and FileHandler append by default. Pass filemode="w" to logging.basicConfig() or mode="w" to logging.FileHandler() and the file is truncated each time the script starts.
Can several processes write to the same log file?
Not safely. The Python docs say logging to a single file from multiple processes is not supported. Send the records from your worker processes to one process with a QueueHandler or SocketHandler, and let that process write the file.
How do I log an exception with its traceback?
Call log.exception("your message") inside an except block. It logs the message at ERROR level and attaches the current exception's traceback.
Is Python logging thread-safe?
Yes. The logging module uses locks so that threads in one process can share loggers and handlers without any extra work in your code.
Check on long runs from your phone
Change one import and every tqdm bar prints a link you can open on any device. Get a Discord or Slack message when the run finishes, fails or stalls.
pip install tqdm-liveSources
- Python docs: logging docs.python.org
- Python docs: logging.handlers docs.python.org
- Python docs: Logging HOWTO docs.python.org
- Python docs: Logging Cookbook docs.python.org
- tqdm docs: contrib.logging tqdm.github.io