Logging

When you’re writing Python scripts, you often need to see what the program is doing—especially when something goes wrong. Up to this point, we’ve been using arbitraty print statements in our code as a way to debug errors. Logging is a better way to track what happens at runtime and allows us to diagnose what parts of our code are causing errors.

After this module, you should be able to:

  • Import the logging module and call logging.basicConfig()

  • Choose a log level: DEBUG, INFO, WARNING, ERROR, or CRITICAL

  • Write messages at the right level

  • Use logs to track down where warnings or errors came from

Why use logging instead of print?

Logging has several advantages over using print statements for debugging and tracking any errors or misbehavior within our code. Logging allows you to specify the importance of messages (log levels), configure where the messages should be sent (console, log files, etc.), and more.

  • print() — Use for the normal output your program is supposed to show (e.g. results to the user).

  • Logging — Use for everything else: extra detail for debugging, warnings that don’t stop the program, errors that cause something to fail, and critical problems that prevent the program from continuing.

With logging, you can leave all those diagnostic messages in your code and control how much you see by changing one setting (the log level). No need to delete or comment out print statements.

Log levels

Log levels indicate how important or severe a message is. Python’s logging module (in the standard library) defines five standard levels, from most to least verbose:

Level

Use

Example

DEBUG

Capture detailed diagnostic information, typically only of interest during development.

“Parsed 152 records from input file”

INFO

Confirm things are working as expected; provide general information about the workflow.

“Successfully loaded 4HHB.cif”

WARNING

Indicate potential issues that may require attention.

“Missing values detected in 3 records”

ERROR

Record problems that impact some functionality of the application.

“Could not open input file ‘4HHB.cif’”

CRITICAL

Most severe error; program failure has occurred.

“Database connection failed — shutting down application”

If you set the log level to WARNING, you’ll see WARNING, ERROR, and CRITICAL messages, but not DEBUG or INFO. If you set it to DEBUG, you’ll see everything. That way you can leave DEBUG and INFO messages in your code and only enable them when you need to dig into a problem.

(Source: Python Logging How-To.)

Your first logging script

Step 1: Minimal setup

Create a script (e.g. log_test.py) with:

1import logging
2logging.basicConfig()    # turn on the default logging setup
3
4logging.debug('This is a DEBUG message')
5logging.info('This is an INFO message')
6logging.warning('This is a WARNING message')
7logging.error('This is an ERROR message')
8logging.critical('This is a CRITICAL message')

Run it:

$ python3 log_test.py
WARNING:root:This is a WARNING message
ERROR:root:This is an ERROR message
CRITICAL:root:This is a CRITICAL message

By default, the log level is set to WARNING. So you only see WARNING and above; DEBUG and INFO are hidden.

Step 2: Show all messages

To see DEBUG and INFO too, set the level when you call basicConfig:

1import logging
2logging.basicConfig(level=logging.DEBUG)
3
4logging.debug('This is a DEBUG message')
5logging.info('This is an INFO message')
6logging.warning('This is a WARNING message')
7logging.error('This is an ERROR message')
8logging.critical('This is a CRITICAL message')

Now all five messages appear.

Step 3: Set the level from the command line

You can make the log level a command-line option so you don’t have to edit the script each time you want more or less output. To do this, we use argparse, which is Python’s standard library module for reading options provided when a program is run from the command line (for example python3 log_test.py -l DEBUG).

 1import argparse
 2import logging
 3
 4# Create a parser object responsible for reading/understanding command-line arguments
 5parser = argparse.ArgumentParser()
 6
 7# Add one command-line option to the parser
 8parser.add_argument('-l', '--loglevel',
 9                    type=str,
10                    required=False,
11                    default='WARNING',
12                    help='set log level to DEBUG, INFO, WARNING, ERROR, or CRITICAL')
13
14# Create object that will store command-line arguments provided by the user
15args = parser.parse_args()
16
17# Use user-defined log level
18logging.basicConfig(level=args.loglevel)
19
20logging.debug('This is a DEBUG message')
21logging.info('This is an INFO message')
22logging.warning('This is a WARNING message')
23logging.error('This is an ERROR message')
24logging.critical('This is a CRITICAL message')

In this example, we first create a parser object using argparse.ArgumentParser(). Then we use parser.add_argument(...) to tell the parser about one command-line option that our program supports. In this case, we define a log level option:

  • -l and --loglevel: Two ways o specify the same option (short and long forms)

  • type=str: The value we pass via the command-line should be read as a string (e.g., ‘DEBUG’)

  • required=False: The user does not have to provide this option

  • default='WARNING': If the user does not provide a log level, use WARNING automatically

  • help='...': Text that is shown when the user runs --help

Then, we create an object called args that will store the command-line options provided by the user. These are stored using dot notation (e.g., args.loglevel), so we then pass this value to basicConfig.

Examples:

$ python3 log_test.py
# only WARNING and above (default)

$ python3 log_test.py -l DEBUG
# all messages

$ python3 log_test.py --loglevel ERROR
# only ERROR and CRITICAL

Making log messages more useful

Plain messages are okay, but in real projects (and especially when you run the same program on several machines) it helps to include:

  • When the event happened (timestamp)

  • Where it happened (script name, function, line number)

  • Which machine (hostname), if you have multiple servers or containers

You can do that by passing a format string to basicConfig and, if you want the hostname, using the socket module. The socket module lets us ask the operating system for information regarding the system we’re running on. For example, we can use socket.gethostname(), which returns the name of the machine the program is running on.

Here’s an example format string:

format_str = (
    f'[%(asctime)s {socket.gethostname()}] '
    '%(filename)s:%(funcName)s:%(lineno)s - %(levelname)s: %(message)s'
)

This string tells the logging system how each log line should look. There’s two kinds of formatting happening:

  1. Python f-string formatting: happens immediately when the program runs; used here to insert the hostname

  2. Logging placeholders (e.g. %(...)s): these are filled in later by the logging system; each placeholder corresponds to information about the log event.

    • %(asctime)s – The time the log message was created

    • %(filname)s – The name of the Python file that logged the message

    • %(funcName)s – The function where the log call occurred (<module> means the top level of the file)

    • %(linenos)s – The line number where the log call appears

    • %(levelname)s – The log level (DEBUG, INFO, WARNING, etc.)

    • %(message)s – The message you pass to logging.warning(), logging.error(), etc.

A table explaining these attributes and more can be found here.

Here’s an example:

 1import argparse
 2import logging
 3import socket
 4
 5parser = argparse.ArgumentParser()
 6parser.add_argument('-l', '--loglevel',
 7                    type=str,
 8                    required=False,
 9                    default='WARNING',
10                    help='set log level to DEBUG, INFO, WARNING, ERROR, or CRITICAL')
11args = parser.parse_args()
12
13format_str = (
14    f'[%(asctime)s {socket.gethostname()}] '
15    '%(filename)s:%(funcName)s:%(lineno)s - %(levelname)s: %(message)s'
16)
17logging.basicConfig(level=args.loglevel, format=format_str)
18
19logging.debug('This is a DEBUG message')
20logging.info('This is an INFO message')
21logging.warning('This is a WARNING message')
22logging.error('This is an ERROR message')
23logging.critical('This is a CRITICAL message')

A table explaining these attributes and more can be found here.

$ python3 log_test.py
[2026-02-09 21:16:06,512 mbs-337-14] log_test.py:<module>:18 - WARNING: This is a WARNING message
[2026-02-09 21:16:06,512 mbs-337-14] log_test.py:<module>:19 - ERROR: This is an ERROR message
[2026-02-09 21:16:06,512 mbs-337-14] log_test.py:<module>:20 - CRITICAL: This is a CRITICAL message

Exercise: Add logging to the FASTQ summary script

Let’s add logging to our FASTQ summary script. Here’s a few things we could add:

Location

Level

What to log

summarize_record()

DEBUG

Summarizing record record.id

summarize_fastq_file()

INFO

Reading FASTQ file fastq_file

summarize_fastq_file()

INFO

Finished reading x reads

write_summary_to_json()

INFO

Writing summary to output_file

write_summary_to_json()

INFO

Finished writing output_file

main()

INFO

Starting FASTQ summary workfow

main()

INFO

FASTQ summary workflow complete!

 1import json
 2from Bio import SeqIO
 3from models import ReadSummary, FastqSummary
 4
 5# -------------------------
 6# Constants (configuration)
 7# -------------------------
 8FASTQ_FILE = 'raw_reads.fastq'
 9OUTPUT_JSON = 'fastq_summary.json'
10ENCODING = 'fastq-sanger'
11
12# -------------------------
13# Functions
14# -------------------------
15def summarize_record(record) -> ReadSummary:
16    phred_scores = record.letter_annotations['phred_quality']
17    average_phred = sum(phred_scores) / len(phred_scores)
18
19    return ReadSummary(
20        id=record.id,
21        sequence=str(record.seq),
22        total_bases=len(record.seq),
23        average_phred=round(average_phred, 2)
24    )
25
26def summarize_fastq_file(fastq_file: str, encoding: str) -> FastqSummary:
27    reads_list = []
28
29    with open(fastq_file, 'r') as f:
30        for record in SeqIO.parse(f, encoding):
31            reads_list.append(summarize_record(record))
32
33    return FastqSummary(reads=reads_list)
34
35def write_summary_to_json(summary: FastqSummary, output_file: str) -> None:
36    with open(output_file, 'w') as outfile:
37        json.dump(summary.model_dump(), outfile, indent=2)
38
39def main():
40    summary = summarize_fastq_file(FASTQ_FILE, ENCODING)
41    write_summary_to_json(summary, OUTPUT_JSON)
42
43if __name__ == '__main__':
44    main()
 1import json
 2import argparse
 3import logging
 4import socket
 5from Bio import SeqIO
 6from models import ReadSummary, FastqSummary
 7
 8# -------------------------
 9# Constants (configuration)
10# -------------------------
11FASTQ_FILE = 'raw_reads.fastq'
12OUTPUT_JSON = 'fastq_summary.json'
13ENCODING = 'fastq-sanger'
14
15# -------------------------
16# Logging setup
17# -------------------------
18parser = argparse.ArgumentParser()
19parser.add_argument(
20    '-l', '--loglevel',
21    type=str,
22    required=False,
23    default='WARNING',
24    help='set log level to DEBUG, INFO, WARNING, ERROR, or CRITICAL'
25)
26args = parser.parse_args()
27
28format_str = (
29    f'[%(asctime)s {socket.gethostname()}] '
30    '%(filename)s:%(funcName)s:%(lineno)s - %(levelname)s: %(message)s'
31)
32logging.basicConfig(level=args.loglevel, format=format_str)
33
34# -------------------------
35# Functions
36# -------------------------
37def summarize_record(record) -> ReadSummary:
38    logging.debug(f"Summarizing record {record.id}")
39
40    phred_scores = record.letter_annotations['phred_quality']
41    average_phred = sum(phred_scores) / len(phred_scores)
42
43    return ReadSummary(
44        id=record.id,
45        sequence=str(record.seq),
46        total_bases=len(record.seq),
47        average_phred=round(average_phred, 2)
48    )
49
50def summarize_fastq_file(fastq_file: str, encoding: str) -> FastqSummary:
51    logging.info(f"Reading FASTQ file {fastq_file}")
52
53    reads_list = []
54    with open(fastq_file, 'r') as f:
55        for record in SeqIO.parse(f, encoding):
56            reads_list.append(summarize_record(record))
57
58    logging.info(f"Finished reading {len(reads_list)} reads")
59    return FastqSummary(reads=reads_list)
60
61def write_summary_to_json(summary: FastqSummary, output_file: str) -> None:
62    logging.info(f"Writing summary to {output_file}")
63
64    with open(output_file, 'w') as outfile:
65        json.dump(summary.model_dump(), outfile, indent=2)
66
67    logging.info(f"Finished writing {output_file}")
68
69def main():
70    logging.info("Starting FASTQ summary workflow")
71
72    summary = summarize_fastq_file(FASTQ_FILE, ENCODING)
73    write_summary_to_json(summary, OUTPUT_JSON)
74
75    logging.info("FASTQ summary workflow complete")
76
77if __name__ == '__main__':
78    main()

Now try running your script specifying different logging levels with -l and compare the output.

Additional resources