Skip to content
VLSI Mentor

Python Part I · Module 4 · Strings

Formatting Numbers for Reports

A number that is right is not always a number that is readable. A runtime measured by a tool comes out as 182.43999999999997, and a coverage figure as 97.45670000000001, and a column of those is unreadable whatever it says. Chapter 4.12 ended with a colon inside the braces of an f-string; this chapter is what goes after it. There are only three ideas — how many decimal places, how wide the field is, and which way the text sits inside it — and together they turn a stream of values into a table somebody can actually read. The chapter also settles how Python rounds, which is not the rule most people assume, and closes the loop with the bit patterns from Module 3.

Foundation14 min readPythonPart IFundamentalsStringsFormatting

Part I · Module 4 · Chapter 4.13 · Formatting Numbers for Reports

1. The Problem, and Where It Comes From

Here is a runtime as a tool might report it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
runtime = 182.43999999999997
print(runtime)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
182.43999999999997

Nobody wants that in a report. The measurement is 182.44 seconds and the trailing digits are noise.

Before fixing it, it is worth knowing that this is not a bug and not a corrupted value. Chapter 2.6 introduced floats — numbers with a decimal point — and noted that they are stored in a fixed amount of space, which means most decimal fractions cannot be held exactly. The classic demonstration:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(0.1 + 0.2)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
0.30000000000000004

Both numbers are slightly off from the decimal values you wrote, and the error shows up in the sum. Arithmetic on measured values accumulates that, which is how a runtime arrives with fifteen digits of apparent precision when the measurement had four.

The fix is one instruction inside the braces:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
runtime = 182.43999999999997
print(f"{runtime:.1f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
182.4

2. Decimal Places

The syntax is a colon, then ., then how many decimal places, then f for a fixed-point number:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
coverage = 97.4567
print(f"{coverage:.2f}")
print(f"{coverage:.1f}")
print(f"{coverage:.0f}")
print(f"{coverage:.4f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
97.46
97.5
97
97.4567

Read .2f as "two digits after the point". The value is rounded to that many places — 97.4567 to two places is 97.46.

.0f gives no decimal point at all, which is how you present a value as a whole number without converting it.

And a detail people get wrong immediately after learning this:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
coverage = 97.4567
print(f"{coverage:.2f}", coverage)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
97.46 97.4567

Formatting does not change the value. It produces a string that shows the value a particular way. coverage is still 97.4567 and still usable for arithmetic — which is exactly what you want, because you do not lose precision by printing a report.

3. Width and Alignment

Decimal places alone do not make a column line up, because the numbers still have different widths. A field width fixes that: a number after the colon says how many characters the field occupies, padding with spaces if the value is shorter.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"[{42:5d}]")
print(f"[{7:5d}]")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[   42]
[    7]

Five-character fields, with d meaning a whole number. Both are padded to the same width, so in a column they would align on their last digit. Numbers pad on the left by default, which is right-alignment, and that is what you want for numbers — digits of the same significance end up above each other.

Text behaves the other way round by default, and you can state it explicitly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"[{'smoke':<12}]")
print(f"[{'smoke':>12}]")
print(f"[{'smoke':^12}]")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[smoke       ]
[       smoke]
[   smoke    ]

Three alignment characters, and they point the way the text goes:

CharacterMeaningUse for
<pad on the right, text at the leftnames, labels, any text column
>pad on the left, text at the rightnumbers
^pad both sides, text centredheadings

Width and precision combine, in that order — width first, then the decimal places:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"[{95.5:>8.2f}]")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[   95.50]

An eight-character field, right-aligned, two decimal places. Note that 95.5 printed as 95.50: .2f means exactly two places, so it pads with a zero rather than leaving it short. That is what makes a column of them align on the decimal point.

Zero-padding is a variation worth knowing, because sequence numbers and seeds often need it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{7:03d}")
print(f"{4210:03d}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
007
4210

A 0 before the width pads with zeros instead of spaces. And note the second line: a width is a minimum, never a maximum. 4210 needs four characters, so it gets four — a width will not truncate your value, which is the correct behaviour. A silently shortened number would be far worse than a misaligned one.

4. The Payoff: A Table That Lines Up

This is what the chapter is for. Here are three results formatted as a table:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{'axi_smoke':<14}{182.4:>9.1f}{97.46:>8.2f}")
print(f"{'reg_rw':<14}{7.2:>9.1f}{91.2:>8.2f}")
print(f"{'burst_long':<14}{1204.55:>9.1f}{99.0:>8.2f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
axi_smoke         182.4   97.46
reg_rw              7.2   91.20
burst_long       1204.5   99.00

Three columns: a 14-character left-aligned name, a 9-character runtime with one decimal, an 8-character coverage with two. The names start at the same place and the numbers end at the same place, so the table can be read down a column instead of line by line.

Two details in that output are worth checking against the rules above.

91.2 came out as 91.20 and 99.0 as 99.00, because .2f means exactly two places. That is what aligns the decimal points down the column.

And 1204.55 came out as 1204.5 — .1f rounded it to one place, and the nine-character field was wide enough to hold the result with three spaces of padding to spare.

So the table is correct. The way it breaks is worth seeing too, because it breaks in a specific way:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{'axi_smoke':<14}{182.4:>9.1f}{97.46:>8.2f}")
print(f"{'regression_long_name':<14}{1204.55:>9.1f}{99.0:>8.2f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
axi_smoke         182.4   97.46
regression_long_name   1204.5   99.00

The second name is twenty characters and the field is fourteen. Python did not truncate it — it printed the whole name and the field simply grew, which pushed every column to the right of it out of alignment on that row only. The first row is still correct; the table as a whole is not.

The same is true of numbers, which is worth confirming since it is easy to assume a numeric field is fixed:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"[{123456:3d}]")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[123456]

A three-character field holding a six-digit number. Python chose a correct value over a tidy layout, which is the right priority — a silently truncated number would be far worse than a misaligned one.

Writing one print per row is fine for three rows and clearly not the answer for three hundred. Repeating an action is Module 10; the formatting is this chapter, and it does not change when the repetition arrives.

5. Two More Conveniences

A thousands separator, for numbers large enough to be hard to read:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{1234567:,}")
print(f"{1234567.891:,.2f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
1,234,567
1,234,567.89

A percentage, which multiplies by 100 and adds the sign:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{0.9746:.1%}")
print(f"{0.9746:.2%}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
97.5%
97.46%

Note what that expects: a fraction, not a percentage. 0.9746 becomes 97.46%. If your value is already 97.46, formatting it with .2% would give 9746.00%, so use .2f and write the % yourself.

6. Bits and Hex, Closing the Loop With Module 3

Chapter 3.7 taught the bitwise operators and introduced 0b and 0x notation for writing numbers in binary and hexadecimal. The same colon syntax displays a number that way:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{255:x}")
print(f"{255:X}")
print(f"{255:#x}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ff
FF
0xff

x for lowercase hex, X for uppercase, and # to include the 0x prefix so the reader can see which base it is.

Binary works identically with b, and combines with the zero-padding from Section 3:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{5:b}")
print(f"{5:04b}")
print(f"{5:#06b}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
101
0101
0b0101

:04b is the useful one: four binary digits with leading zeros, which is how you display a small bit pattern so that a column of them lines up.

7. How Python Actually Rounds

This deserves a section because the common assumption is wrong, and the correct rule is easy to state.

Ask for zero decimal places on four exact halves:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{0.5:.0f}", f"{1.5:.0f}", f"{2.5:.0f}", f"{3.5:.0f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
0 2 2 4

Not 1 2 3 4. When a value is exactly halfway, Python rounds to the even digit — 0.5 down to 0, 1.5 up to 2, 2.5 down to 2, 3.5 up to 4. The same rule at one decimal place:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{0.25:.1f}", f"{0.75:.1f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
0.2 0.8

0.25 went down to 0.2 and 0.75 up to 0.8, both landing on an even last digit.

This is a deliberate and standard choice: rounding ties always upward biases a long column of numbers slightly high, and alternating by evenness does not.

There is a second effect that matters more in practice, and it comes straight from Section 1:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{2.675:.2f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
2.67

That looks like a tie rounded down, but it is not a tie at all. The float nearest to 2.675 is very slightly below it, so rounding to two places correctly gives 2.67. No rule about ties was involved — the value simply was not halfway.

The built-in round() function follows the same tie rule, for consistency:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(round(0.5), round(1.5), round(2.5))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
0 2 2

8. Broken on Purpose

A whole-number code on a float. d means integer, and Python will not quietly discard the fractional part:

Azvya Education Pvt. Ltd.VLSI Mentor
BROKEN ON PURPOSE
print(f"{182.4:d}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ValueError: Unknown format code 'd' for object of type 'float'

Use .0f if you want it shown without decimals, or int() from Chapter 2.11 if you genuinely want a whole number.

A number code on text. This is the practical one, because it is what happens after split():

Azvya Education Pvt. Ltd.VLSI Mentor
BROKEN ON PURPOSE
runtime = "182.44"
print(f"{runtime:.1f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ValueError: Unknown format code 'f' for object of type 'str'

"182.44" in quotes is text, and .1f is an instruction for a number. Chapter 4.8 established that every field from split() is a string, so this is exactly the error you get when formatting a value that came out of a line of tool output. The fix is to convert first:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
runtime = "182.44"
print(f"{float(runtime):.1f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
182.4

That is Chapter 2.11's conversion and Chapter 2.9's "4210" is not 4210 arriving together at the last chapter of the module, with a consequence you can see.

9. The Specifiers Worth Knowing

A summary of what this chapter covered. Not a reference to memorise — the four in the first group cover nearly everything:

SpecifierResultFor
:.2f97.46fixed decimal places
:<12smoke·······left-aligned text column
:>8·····95.5right-aligned number column
:>8.2f···95.50both together — the table idiom
:03d007zero-padded whole number
:,1,234,567thousands separators
:.1%97.5%a fraction shown as a percentage
:04b0101binary digits, zero-padded
:#x0xffhexadecimal with its prefix

The dots in that table stand for spaces, so you can see the padding.

10. Predict, Run, Observe

Predict all six before reading on. Two of them are the traps from this chapter.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
v = 97.4567
print(f"{v:.2f}")
print(f"{v:.0f}")
print(f"[{v:10.1f}]")
print(f"[{'ok':<6}]")
print(f"{2.5:.0f}")
print(v)

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
97.46
97
[      97.5]
[ok    ]
2
97.4567

The third pads to ten characters and right-aligns, because numbers pad left by default.

The fifth is 2, not 3 — the tie went to the even digit, which is Section 7.

The sixth is the other trap: v is unchanged. Every line above produced a string showing v a particular way, and none of them touched the value. If you predicted 97.46 on the last line you assumed formatting modifies the number, which is the assumption Section 2 exists to remove — and it is the same shape of misunderstanding as the string methods in Chapter 4.11.

11. Exercises

Exercise 1 — decimal places. For x = 3.14159, give the output of each without running it, then verify: f"{x:.0f}", f"{x:.1f}", f"{x:.3f}", f"{x:.6f}".

Exercise 2 — make it line up. Using the values ("smoke", 12.5), ("regression_long", 1834.25) and ("burst", 7.0), write three print calls producing a table where the names are left-aligned and the runtimes right-aligned with one decimal place. Choose your widths so that nothing overflows, then say how you chose the name width.

Exercise 3 — the tidy wrong answer. Explain in two sentences why rounding a measured value to two decimals and then summing several of them can give a different total from summing first and rounding at the end. Say which order is correct.

Exercise 4 — read the errors. Run both and write down the exact messages. Then say which one you are more likely to hit in a real script and why:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
print(f"{182.4:d}")
print(f"{'182.4':.1f}")

Exercise 5 — after split(). Given line = "smoke 4210 PASS 182.44", write one expression that prints the runtime with one decimal place. You will need a conversion; name the chapter that taught it.

Exercise 6 — the tie rule. Predict f"{0.5:.0f}", f"{1.5:.0f}" and f"{2.5:.0f}", run them, and state the rule in one sentence. Then say why this almost never matters for a report and when it would.

Exercise 7 — width is a minimum. Predict the output of f"[{123456:3d}]" and explain in one sentence why Python behaves that way rather than truncating.

Exercise 8 — bits. Using the formatting from Section 6, print the numbers 1, 2, 4 and 8 as four-digit binary with leading zeros, one per line. Then say in one sentence why :04b does not mean the value is four bits wide.

Exercise 9 — percentage or not. A coverage figure arrives as 97.46. Write the f-string that displays it as 97.46%, and say why :.2% would be wrong here.

12. Summary

  • A colon inside f-string braces introduces formatting instructions for the value.
  • :.Nf gives exactly N decimal places, rounding for display. f"{182.43999999999997:.1f}" is 182.4.
  • Long trailing digits are normal for floats, not corruption — most decimals cannot be held exactly, as Chapter 2.6 explained. Control the display, not the value.
  • Formatting never changes the value. It builds a string. Do arithmetic on full values and format only at output.
  • A number after the colon is a field width, padding with spaces. Numbers pad left (right-aligned); text pads right (left-aligned).
  • <, > and ^ set alignment explicitly — left, right, centred. < for name columns, > for numbers.
  • Width and precision combine as :>8.2f — the table idiom.
  • A width is a minimum, never a maximum. A value needing more space gets it, and one overflowing field shifts every column to its right on that row. Choose widths for your longest realistic value.
  • :03d zero-pads, :, adds thousands separators, :.1% shows a fraction as a percentage — so pass 0.9746, not 97.46.
  • :04b and :#x display bit patterns and hex, closing the loop with Chapter 3.7. The width is a display choice, not a register width.
  • Ties round to the even digit. f"{2.5:.0f}" is 2. Do not rely on .5 going up.
  • :d on a float and :.1f on a string are both ValueError. The second is the common one, because split() produces text — convert with float() first.

That completes Module 4 — Strings.

You started the module able to write a piece of text and compare it. You can now write any text you need including quotes, tabs and multiple lines; reach a single character or a run of them; measure text and test what it contains; normalise case and whitespace so comparisons mean what you intended; break a line into its fields and assemble one from pieces; test how text begins and ends, locate a marker and substitute text; explain precisely why every one of those operations returns a new string rather than changing yours; and build new text with values placed and formatted inside it.

That is the complete plain-string toolkit, and it is enough to take a line of real tool output apart and produce a readable report from it — with no pattern language, no libraries and nothing imported.

One thing is still missing, and you will have felt it. You can produce the answer to a question — "ERROR" in line — but you cannot yet act differently depending on it, and you cannot yet do the same work to a hundred lines instead of one. Those are the next few modules. The immediate one is the collection that split() has been handing back since Chapter 4.8: Module 5 — Lists is where the pieces stop being something you glance at and become something you work with.

Where this fits

Part of the Python curriculum.