Skip to content
Python Basics: Build Readable Reports with Format Specifications

Python Basics: Build Readable Reports with Format Specifications

October 25, 2026

A terminal report is easier to scan when labels line up and numbers use a consistent precision. Python’s format specifications control the presentation of individual values inside f-strings or through the format function. The values themselves stay available for calculation.

The examples use small ASCII labels and invented measurements. They do not write reports to disk or depend on the machine’s locale.

Prepare a scratch directory

These examples were tested on Linux with Python 3.12.14. Use Python 3.7 or newer for guaranteed dictionary insertion order. Use Bash, a text editor, and the standard mktemp, rm, and rmdir utilities. No third-party packages are required. Run without -O so the assertions remain enabled.

Create a fresh directory and keep using the same terminal:

start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

If either setup command fails, stop before saving any files. Save each named source below as UTF-8.

1. Align columns and deliberately shorten labels

Save as align_report_columns.py:

rows = [("pages", 12), ("images", 384), ("very-long-label", 2)]
lines = [f"|{'Name':<10}|{'Items':>6}|"]
for label, count in rows:
    lines.append(f"|{label:<10.10s}|{count:>6d}|")

assert all(len(line) == 19 for line in lines)
assert "very-long-" in lines[-1]
for line in lines:
    print(line)

long_number = format(1234567, ">6d")
assert long_number == "1234567"
print("Width is a minimum:", long_number)

Run it:

python3 -B align_report_columns.py

Output:

|Name      | Items|
|pages     |    12|
|images    |   384|
|very-long-|     2|
Width is a minimum: 1234567

For these strings, <10 left-aligns in at least ten characters, while .10s also limits the label to ten characters. The count uses right alignment and decimal integer presentation. Numeric width does not truncate: a seven-digit count grows beyond a six-character field. See the format specification mini-language.

This table intentionally shortens a long label. Preserve the original label elsewhere if that loss would confuse a reader. Character counts also do not guarantee terminal column widths for emoji, combining marks, or wide characters.

2. Choose a representation for each number

Save as format_report_numbers.py:

measurement = 12345.5
completion = 0.375
delta = -3.0
identifier = 42

rendered = {
    "Measurement": f"{measurement:,.2f}",
    "Completion": f"{completion:.1%}",
    "Signed change": f"{delta:+07.2f}",
    "Hex identifier": format(identifier, "#06x"),
}
assert rendered == {
    "Measurement": "12,345.50",
    "Completion": "37.5%",
    "Signed change": "-003.00",
    "Hex identifier": "0x002a",
}
for label, value in rendered.items():
    print(f"{label}: {value}")
assert completion == 0.375
assert identifier == 42
print("Underlying identifier:", identifier)

Run it:

python3 -B format_report_numbers.py

Output:

Measurement: 12,345.50
Completion: 37.5%
Signed change: -003.00
Hex identifier: 0x002a
Underlying identifier: 42

.2f gives two fractional digits; the comma groups thousands. .1% displays a ratio as a percentage. The signed field includes its sign in the width and places zero padding after it. # adds the hexadecimal prefix. These choices are presentation only, and the original numeric values are unchanged.

The punctuation here is explicit, not locale-aware. Floating-point representation can also affect rounding at boundaries. Choose an appropriate numeric model before formatting financial or other exact-decimal data.

3. Build a bounded specification from options

Save as bound_dynamic_format.py:

def format_measurement(value, *, width, places):
    if type(width) is not int or not 4 <= width <= 40:
        raise ValueError("width must be an integer from 4 to 40")
    if type(places) is not int or not 0 <= places <= 6:
        raise ValueError("places must be an integer from 0 to 6")
    specification = f">{width}.{places}f"
    return format(value, specification)


padded = format_measurement(12.5, width=10, places=3)
wide_value = format_measurement(12345.0, width=4, places=1)
assert padded == "    12.500"
assert wide_value == "12345.0"
print("Padded:", repr(padded))
print("Wider value:", repr(wide_value))

for width, places in [(True, 2), (1000, 2), (10, 8)]:
    try:
        format_measurement(12.5, width=width, places=places)
    except ValueError as error:
        print("Rejected:", error)
    else:
        raise AssertionError("Invalid display options were accepted")

Run it:

python3 -B bound_dynamic_format.py

Output:

Padded: '    12.500'
Wider value: '12345.0'
Rejected: width must be an integer from 4 to 40
Rejected: width must be an integer from 4 to 40
Rejected: places must be an integer from 0 to 6

format receives the specification without braces or a colon. Constructing it from validated options keeps the supported interface small. The type checks intentionally reject booleans, and the bounds reject excessive width and precision. They do not limit the magnitude of the input value or guarantee a maximum output length.

The format built-in delegates formatting to the value’s type. f-strings use that formatting machinery too; their language reference explains replacement expressions and specifications. Keep format strings under application control and validate data separately. Formatting is not HTML escaping, SQL parameterization, or shell quoting.

Clean up

In the same terminal, remove only these sources and the empty scratch directory:

rm -- align_report_columns.py format_report_numbers.py bound_dynamic_format.py
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

If rmdir reports remaining files, inspect them instead of deleting unknown contents.

References

Acknowledgements

This post was created with assistance from AI.

Last updated on