← Back to Core Python
Lesson 7 · Python Foundations

String Formatting

f-strings, str.format, number formatting, and alignment.

Beginner35 min

What you will be able to do

  • Build strings from text and values with f-strings
  • Read and write a format spec, rather than memorising examples
  • Format money, percentages, padded IDs, and aligned columns
  • Put expressions and method calls inside an f-string - and know when not to
  • Use f"{value=}" and !r while debugging
  • Explain why formatting changes the display and never the value

The idea, in plain English

Almost every message a program produces mixes fixed text with values that change: a name, a total, a percentage. String formatting is how you build those, and Python has accumulated four ways of doing it over the years.

Concatenation came first and is the worst of them - it needs str() around every number, and by the third variable the quotes and plus signs outnumber the words. % formatting came next, then str.format(), and since Python 3.6 the f-string, which is what you should write today.

An f-string is an ordinary string with an f before the opening quote. Inside it, anything in braces is evaluated and its result dropped into place. Add a colon and you can say how it should look as well as what it is.

That colon is the part worth learning properly. Everything after it is a small language of its own - the same one used by str.format() and by format() - and once you can read it you stop looking up "python two decimal places" forever.

Worked example: Turning 1250000.567 into the line "Total: 1,250,000.57".

Four ways, and the one to use

All four produce the same string. They differ in how much of what you wrote is about the values and how much is punctuation holding it together.

Write f-strings. Learn to read % formatting, because it survives in logging calls and older code, and recognise str.format() when you meet it - it is still the right tool when the template and the values are separated, such as a message string loaded from a config file.

The same message, four ways
Concatenation"Hello " + name + ", you are " + str(age) - every number needs str(), and it gets unreadable fast.
% formatting"Hello %s, you are %d" % (name, age) - the oldest style. Still common in logging: log.info("user %s", uid).
str.format()"Hello {}, you are {}".format(name, age) - useful when the template is defined away from the values.
f-stringf"Hello {name}, you are {age}" - shortest, fastest, and the values sit where they appear. Use this.

Tip: f-strings are evaluated where they are written, so they cannot be used as a reusable template. When you need a template filled in later, str.format() is the right tool.

The format spec, decoded

Everything after the colon follows one grammar, and it is shorter than it looks: [fill][align][sign][width][,][.precision][type]. Every part is optional, and you will rarely use more than three at once.

Read f"{total:>12,.2f}" left to right: right-align, in a field 12 wide, with thousands separators, to two decimal places, as a fixed-point number. That is one expression doing what four lines of string surgery used to.

One default worth knowing: in a width field, strings align left and numbers align right. That is usually what you want for a table, and it is confusing the first time you set a width and things move in different directions.

[fill][align][sign][width][,][.precision][type]
fillAny character to pad with. Needs an align after it: :*^20 centres and pads with asterisks.
align< left, > right, ^ centre. Strings default to left, numbers to right.
sign+ shows a plus on positives, which lines up columns of gains and losses.
widthMinimum total characters. :05 pads an integer with zeros to width 5.
,Thousands separators. Use _ instead for 1_250_000 if you prefer.
.precisionDigits after the point for f, significant digits for g, characters for strings.
typef fixed-point, % percentage, e scientific, d integer, b/o/x binary/octal/hex, s string.

Percentage formatting expects a fraction

The % type multiplies by 100 and appends a percent sign. So it wants 0.75, not 75. Passing the number you already think of as a percentage gives you 7500.0%, which is the single most common formatting bug.

The rule: if the value came from a division - completed / total - it is already a fraction and :.1% is right. If you multiplied by 100 to "make it a percentage", use :.1f and write the % sign yourself.

Getting a percentage on screen
0.75 with :.0%"75%" - correct. The value is a fraction and % scales it.
0.75 with :.1%"75.0%" - one decimal place.
75 with :.1%"7500.0%" - wrong. The value was already scaled and % scaled it again.
75 with :.1f"75.0" - then add the sign yourself: f"{value:.1f}%".

Watch out: Formatting changes only what is displayed. f"{price:.2f}" produces a new string; price itself keeps every digit it had.

Syntax and examples

The four approaches, side by side
name, age = "Chandu", 34 print("Hello " + name + ", you are " + str(age)) # concatenation print("Hello %s, you are %d" % (name, age)) # % formatting print("Hello {}, you are {}".format(name, age)) # str.format() print(f"Hello {name}, you are {age}") # f-string
Expressions inside the braces
price, quantity = 1000, 3 skills = ["Python", "React", "Node.js"] user = {"name": "Chandu", "role": "Developer"} print(f"Total: {price * quantity}") # arithmetic print(f"Name: {name.upper()}") # a method call print(f"Skills: {', '.join(skills)}") # a whole expression print(f"Role: {user['role']}") # note the inner quotes differ print(f"Status: {'Adult' if age >= 18 else 'Minor'}") # a conditional
Numbers: money, percentages, padding
price = 1250000.5678 print(f"{price:.2f}") # 1250000.57 two decimal places print(f"{price:,.2f}") # 1,250,000.57 with separators print(f"{price:,.0f}") # 1,250,001 rounded to whole progress = 0.8567 print(f"{progress:.1%}") # 85.7% % expects a fraction invoice = 42 print(f"INV-{invoice:05}") # INV-00042 zero-padded to width 5 print(f"{255:b} {255:o} {255:x} {255:X}") # 11111111 377 ff FF print(f"{1234.5678:.2e}") # 1.23e+03
Width and alignment build tables
students = [("Chandu", 87.5), ("Ravi", 92.0), ("Priya", 78.25)] print(f"{'Name':<12}{'Score':>8}") print("-" * 20) for name, score in students: print(f"{name:<12}{score:>8.1f}") # Name Score # -------------------- # Chandu 87.5 # Ravi 92.0 # Priya 78.2 print(f"{' Report ':=^30}") # ========== Report ===========
Two things that save debugging time
name, price, quantity = "Chandu", 100, 3 # = prints the expression as well as its value print(f"{name=}") # name='Chandu' print(f"{price * quantity=}") # price * quantity=300 print(f"{price * quantity=:,.2f}") # price * quantity=300.00 # !r shows the repr, so you can see quotes and whitespace value = " spaced " print(f"[{value}]") # [ spaced ] print(f"{value!r}") # ' spaced ' <- the spaces are visible
Multiline reports, literal braces, and dates
from datetime import datetime student, course = "Chandu", "Core Python" completed, total = 6, 75 progress = completed / total print(f"""Student: {student} Course: {course} Lessons: {completed}/{total} Progress: {progress:.1%} Printed: {datetime.now():%d-%m-%Y %H:%M}""") # Double the braces to print one print(f"{{Hello}}") # {Hello} # Dynamic precision: the spec itself can contain a placeholder places = 3 print(f"{3.14159:.{places}f}") # 3.142
Calculate first, format second
price, quantity, discount = 3499.50, 2, 0.10 # Work out the numbers... subtotal = price * quantity discount_amount = subtotal * discount final = subtotal - discount_amount # ...then decide how they look. print(f"Product: Mechanical Keyboard") print(f"Price: {price:>12,.2f}") print(f"Quantity: {quantity:>12}") print(f"Subtotal: {subtotal:>12,.2f}") print(f"Discount: {discount:>12.0%}") print(f"You save: {discount_amount:>12,.2f}") print(f"Total: {final:>12,.2f}")

Tip: Before Python 3.12 an f-string expression cannot contain a backslash, so f"{'\n'.join(x)}" is a syntax error. Put the separator in a variable first.

Format specs worth memorising

Nine shapes that cover almost everything you will format.

:.2f

Two decimal places. The everyday number format.

f"{3.14159:.2f}"   # 3.14
:,

Thousands separators.

f"{1250000:,}"     # 1,250,000
:,.2f

Both together - the money format.

f"{1250.5:,.2f}"   # 1,250.50
:.1%

Percentage from a fraction, one decimal place.

f"{0.857:.1%}"     # 85.7%
:05

Zero-pad an integer to width 5.

f"{42:05}"         # 00042
:<12 :>12 :^12

Left, right, centre within a width of 12.

f"{name:<12}"
:*^20

Centre in 20 characters, padding with asterisks.

f"{'Hi':*^20}"
:.2e

Scientific notation.

f"{1234.5:.2e}"    # 1.23e+03
:b :o :x :X

Binary, octal, lower and upper hex.

f"{255:x}"         # ff

f-string extras and date codes

{value=}

Prints the expression text as well as the value. Pure debugging gold.

f"{age=}"   # age=34
{value!r}

Uses repr() instead of str(), so quotes and whitespace show.

f"{s!r}"
{{ }}

Doubled braces produce one literal brace.

f"{{x}}"   # {x}
{v:.{n}f}

The spec can itself contain a placeholder, for precision decided at runtime.

f"{pi:.{n}f}"
%Y-%m-%d

Dates format directly in an f-string - year, month, day.

f"{now:%Y-%m-%d}"
%H:%M:%S

Hour, minute, second. %d-%m-%Y %H:%M is the common report stamp.

f"{now:%H:%M}"

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

The percentage trap

“print(f"{0.75:.1%}", f"{75:.1%}")”

Strings and numbers align differently

“print(f"[{'ab':10}]", f"[{12:10}]")”

Self-documenting debug

“x = 7; print(f"{x * 6=}")”

Precision from a variable

“n = 4; print(f"{3.14159265:.{n}f}")”

What usually goes wrong

Forgetting the f

Without it the braces are just characters, and Python prints them literally. No error, no clue - the output simply contains {name}.

✗ message = "Hello {name}"     # Hello {name}
✓ message = f"Hello {name}"
Passing a percentage to the % type

The % format multiplies by 100. Give it 75 and you get 7500.0%. It wants the fraction that came out of the division.

✗ f"{75:.1%}"         # 7500.0%
✓ f"{0.75:.1%}"       # 75.0%
Thinking formatting rounds the value

f"{price:.2f}" builds a string. The float keeps every digit it had, so a later calculation uses the full value - which is correct, and occasionally surprising.

✗ price = f"{price:.2f}"   # price is now a string
✓ display = f"{price:.2f}"  # keep the number and the text apart
Putting real logic inside the braces

An f-string is for presentation. Once the expression has an and, an or, and a condition in it, nobody can read the sentence any more and no debugger can stop inside it.

✗ f"Status: {'OK' if a and b and not c else 'No'}"
✓ status = "OK" if a and b and not c else "No"
f"Status: {status}"
Reusing the same quote inside the braces

Before Python 3.12 the expression cannot use the same quote character that delimits the string. Use the other kind inside.

✗ f"Role: {user["role"]}"
✓ f"Role: {user['role']}"
Building JSON by hand

It works until a value contains a quote, a newline, or a non-ASCII character, and then it produces something that is not valid JSON at all.

✗ body = f'{{"name": "{name}"}}'
✓ import json
body = json.dumps({"name": name})
Writing one very long f-string

Adjacent string literals are joined automatically, so a long message can be split across lines inside parentheses with no + and no backslashes.

✗ msg = f"Hello {name}, welcome to {course}. Progress {p:.0%}. Next: {nxt}."
✓ msg = (
    f"Hello {name}, welcome to {course}. "
    f"Progress {p:.0%}. Next: {nxt}."
)

Best practices

  • Use f-strings for new code; reach for str.format() only when the template is separate from the values.
  • Calculate above, format below. The f-string should decide how a value looks, not what it is.
  • Keep the number and its display separate - never reassign a variable to its own formatted text.
  • Use :,.2f for money, :.1% for a fraction you want as a percentage, and :0Nd for padded identifiers.
  • Split long messages across adjacent f-string literals inside parentheses.
  • Use f"{value=}" while debugging instead of writing the name twice.
  • Use json.dumps() for JSON and the logging module’s own %s arguments for logs, not f-strings.

Practice

Write these yourself before opening anything. Getting them wrong first is most of how this sticks.

1.

With name = "Chandu" and age = 34, print "My name is Chandu and I am 34 years old." using an f-string.

Show solution
name = "Chandu" age = 34 print(f"My name is {name} and I am {age} years old.")
2.

Given price = 1200 and quantity = 3, print "Total: 3,600.00".

Show hint

Work out the total first, then use the money format.

Show solution
price = 1200 quantity = 3 total = price * quantity print(f"Total: {total:,.2f}") # Total: 3,600.00
3.

Given completed = 45 and total = 75, print "Progress: 60.0%".

Show hint

The division already gives you a fraction, which is what :.1% wants.

Show solution
completed = 45 total = 75 progress = completed / total print(f"Progress: {progress:.1%}") # Progress: 60.0%
4.

Display amount = 1256789.456 as "1,256,789.46".

Show solution
amount = 1256789.456 print(f"{amount:,.2f}") # 1,256,789.46
5.

Turn invoice_id = 42 into "INV-00042".

Show solution
invoice_id = 42 print(f"INV-{invoice_id:05}") # INV-00042
6.

Print "Python" three times in a width of 20 - left, right and centre aligned. Put square brackets around each so the padding is visible.

Show solution
name = "Python" print(f"[{name:<20}]") # [Python ] print(f"[{name:>20}]") # [ Python] print(f"[{name:^20}]") # [ Python ]
7.

With name = "Chandu" and age = 34, print name='Chandu' and age=34 without typing the variable names inside the text.

Show hint

There is a single character that does this.

Show solution
name = "Chandu" age = 34 print(f"{name=}") # name='Chandu' print(f"{age=}") # age=34
8.

Print a three-row table of ("Chandu", 87.5), ("Ravi", 92.0), ("Priya", 78.25) with names left-aligned in 12 characters and scores right-aligned to one decimal place.

Show solution
students = [("Chandu", 87.5), ("Ravi", 92.0), ("Priya", 78.25)] print(f"{'Name':<12}{'Score':>8}") print("-" * 20) for name, score in students: print(f"{name:<12}{score:>8.1f}")
Coding challenge

Student progress report

Produce a report card from raw numbers. Nothing displayed may be typed in by hand - every value on screen has to be calculated or formatted from the inputs.

It should
  • Start from a student, a course, completed and total lessons, and a score
  • Work out the progress percentage and the lessons remaining yourself
  • Show progress and score to one decimal place
  • Use a single multiline f-string rather than many print calls
  • Frame it with a rule, and align the labels so the values line up
Start here
student = "Chandu" course = "Core Python" completed_lessons = 6 total_lessons = 75 score = 87.5 # calculate, then format
Show one solution
One solution
student = "Chandu" course = "Core Python" completed_lessons = 6 total_lessons = 75 score = 87.5 # Calculate first - the f-string only decides how these look. progress = completed_lessons / total_lessons remaining = total_lessons - completed_lessons rule = "=" * 40 report = f"""{rule} {'Student:':<20}{student} {'Course:':<20}{course} {'Progress:':<20}{progress:.1%} {'Lessons remaining:':<20}{remaining} {'Score:':<20}{score:.1f}% {rule}""" print(report) # ======================================== # Student: Chandu # Course: Core Python # Progress: 8.0% # Lessons remaining: 69 # Score: 87.5% # ========================================

Key points

  • An f-string is a string with f before the quote; anything in braces is evaluated and inserted.
  • The colon starts a format spec: [fill][align][sign][width][,][.precision][type].
  • :,.2f is money, :.1% is a percentage, :05 pads an integer, :<12 and :>12 align in a column.
  • The % type multiplies by 100, so it expects 0.75 rather than 75.
  • In a width field, strings align left by default and numbers align right.
  • Formatting produces a new string and never changes the value being formatted.
  • f"{value=}" prints the expression and its value - the fastest debug print there is.
  • !r shows the repr, which makes quotes and stray whitespace visible.
  • Doubled braces {{ }} produce one literal brace.
  • Adjacent f-string literals inside parentheses join automatically, which keeps long messages readable.
  • Calculate above the f-string; keep logic out of the braces.
  • Use json.dumps() for JSON rather than building it by hand.

Quick check before you move on

What does f"{1234.567:.2f}" print?
1234.57 - two decimal places, rounded. No thousands separator, because there is no comma in the spec.
What does f"{0.75:.0%}" print, and what would f"{75:.0%}" print?
75% and 7500%. The % type multiplies by 100, so it needs the fraction rather than the already-scaled number.
What does f"{42:05}" print?
00042 - zero-padded to a total width of five characters.
What does f"{{{name}}}" print when name is "Python"?
{Python}. The outer doubled braces produce one literal brace each, and the middle pair is the placeholder.

Interview questions

Why are f-strings faster than the alternatives?

They are compiled into bytecode that builds the string directly, rather than a runtime call that parses a template and looks up arguments. % and .format() both have to interpret the template each time the line runs; an f-string has already been parsed at compile time.

When would you still use str.format() or % formatting?

str.format() when the template and the values are separated - a message loaded from a config file or a translation catalogue - because an f-string is evaluated where it is written and cannot be stored as a template. % formatting when passing arguments to the logging module, where log.info("user %s", uid) defers the interpolation so it never happens if the level is filtered out.

Why is f"user {uid} failed" discouraged in a logging call?

Because it builds the string unconditionally, even when the log level means the message is thrown away. Passing the arguments separately lets logging skip the work entirely, which matters on a debug line inside a hot loop.

What are the security implications of formatting user input?

f-strings are evaluated at compile time from code you wrote, so they are safe. str.format() on an attacker-controlled template is not - a format string can reach attributes through {0.__class__}, which has been used to read secrets out of objects. Never call .format() on a template that came from outside.

How do you format a number to a precision decided at runtime?

Nest a placeholder inside the spec: f"{value:.{places}f}". The spec is itself formatted first, so places can be any expression. The same works for width.

What is the difference between str() and repr(), and how does that reach f-strings?

str() is for people and repr() is for developers - repr aims to show something unambiguous, usually valid Python. An f-string uses str() by default and !r switches to repr, which is why f"{s!r}" makes quotes and trailing whitespace visible when f"{s}" hides them.

Quiz

  1. 1.

    What is an f-string?

  2. 2.

    What does :,.2f mean?

  3. 3.

    Does f"{price:.2f}" change price?

  4. 4.

    Why is f"{75:.1%}" wrong when you want 75%?

  5. 5.

    How do you print a literal brace?

  6. 6.

    What does f"{name=}" produce?

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...