Printing a number to 2 decimal places in Python is a job for an f-string and the .2f format spec:
price = 3.14159
print(f"{price:.2f}") # 3.14
That gives you a string. round(price, 2) gives you a number instead, and the two behave differently often enough to be worth separating properly.
Below: what .2f actually means, why round(2.5) is 2, and what to use for money. Output is from Python 3.12.5.

Print a float with 2 decimal places
Three ways to write it, all producing the same text:
price = 3.14159
print("f-string ->", f"{price:.2f}")
print("format() ->", format(price, ".2f"))
print("% operator ->", "%.2f" % price)
print()
print("all three give the same text. The f-string is the modern one.")
print()
print("used inside a sentence:")
print(f" the total is {price:.2f} dollars")
Output:
f-string -> 3.14
format() -> 3.14
% operator -> 3.14
all three give the same text. The f-string is the modern one.
used inside a sentence:
the total is 3.14 dollars

format() and % all produce the same result.The f-string is the current style and reads best inside a sentence. The % form still appears in older code and in logging calls, where it has a genuine advantage.
What .2f means in Python
The spec is three separate pieces, which is worth seeing spelled out:
value = 1234.5678
print("reading the format spec .2f one piece at a time:")
print()
print(" f means fixed-point: always show the decimal part")
print(" 2 means how many digits after the point")
print(" . separates the number from the kind of formatting")
print()
print("so changing the 2 changes the digits:")
for places in (0, 1, 2, 4):
print(f" .{places}f -> {value:.{places}f}")
print()
print("and f can be swapped for another kind:")
print(f" .2f -> {value:.2f} fixed point")
print(f" .2e -> {value:.2e} scientific")
print(f" .2% -> {0.1234:.2%} percentage")
Output:
reading the format spec .2f one piece at a time:
f means fixed-point: always show the decimal part
2 means how many digits after the point
. separates the number from the kind of formatting
so changing the 2 changes the digits:
.0f -> 1235
.1f -> 1234.6
.2f -> 1234.57
.4f -> 1234.5678
and f can be swapped for another kind:
.2f -> 1234.57 fixed point
.2e -> 1.23e+03 scientific
.2% -> 12.34% percentage
f asks for fixed-point notation, meaning a plain decimal number with no exponent. The 2 sets how many digits follow the point. The dot separates them.
Swapping the f changes the style entirely: e gives scientific notation and % multiplies by 100 and adds a percent sign.
Python f-string 2 decimal places versus round()
They look interchangeable and are not:
price = 3.14159
formatted = f"{price:.2f}"
rounded = round(price, 2)
print("f-string ->", formatted, " type:", type(formatted).__name__)
print("round() ->", rounded, " type:", type(rounded).__name__)
print()
print("one gives you TEXT for display, the other gives a NUMBER")
print("you can keep calculating with.")
print()
print("and they disagree about trailing zeros:")
print(" f'{5:.2f}' ->", f"{5:.2f}")
print(" round(5, 2) ->", round(5, 2))
print(" f'{2.5:.2f}' ->", f"{2.5:.2f}")
print(" round(2.5, 2) ->", round(2.5, 2))
print()
print("formatting pads to exactly two places. round() just rounds,")
print("so a number with nothing to show loses the zeros.")
Output:
f-string -> 3.14 type: str
round() -> 3.14 type: float
one gives you TEXT for display, the other gives a NUMBER
you can keep calculating with.
and they disagree about trailing zeros:
f'{5:.2f}' -> 5.00
round(5, 2) -> 5
f'{2.5:.2f}' -> 2.50
round(2.5, 2) -> 2.5
formatting pads to exactly two places. round() just rounds,
so a number with nothing to show loses the zeros.

round() returns a number and does not.f"{price:.2f}" produces text for display. round(price, 2) produces a number you can keep calculating with. Using the wrong one gives you a string where you wanted arithmetic, or a bare 5 where you wanted 5.00.
The padding difference is the visible one. Formatting always shows exactly two places; rounding drops trailing zeros because a float has no concept of them.
Keep the full-precision number in your variables and format only at the point of display. Rounding early loses information you may need. Converting text back to a number is the other half of that round trip.
Why Python round(2.5) gives 2 instead of 3
This is the result people report as a bug:
print("Python does NOT round a tie upwards. It rounds to the nearest")
print("EVEN number, which is called banker's rounding:")
print()
for value in (0.5, 1.5, 2.5, 3.5, 4.5, 5.5):
print(f" round({value}) -> {round(value)}")
print()
print("0.5 goes down to 0 and 1.5 goes up to 2, because 0 and 2 are even.")
print()
print("it exists so that rounding a long column of numbers does not")
print("drift upwards, which repeated round-half-up would cause.")
print()
print("f-strings do the same thing:")
for value in (0.5, 1.5, 2.5):
print(f" f'{{{value}:.0f}}' -> {value:.0f}")
Output:
Python does NOT round a tie upwards. It rounds to the nearest
EVEN number, which is called banker's rounding:
round(0.5) -> 0
round(1.5) -> 2
round(2.5) -> 2
round(3.5) -> 4
round(4.5) -> 4
round(5.5) -> 6
0.5 goes down to 0 and 1.5 goes up to 2, because 0 and 2 are even.
it exists so that rounding a long column of numbers does not
drift upwards, which repeated round-half-up would cause.
f-strings do the same thing:
f'{0.5:.0f}' -> 0
f'{1.5:.0f}' -> 2
f'{2.5:.0f}' -> 2

Python rounds a tie to the nearest even number rather than always upwards. 0.5 becomes 0, 1.5 becomes 2, and 2.5 becomes 2.
It is deliberate. Always rounding halves upwards biases a long column of numbers slightly high, and rounding to even cancels that out across many values.
f-strings follow the same rule, so switching to formatting does not change the behaviour.
Why Python round(2.675, 2) gives 2.67
A different cause, with the same flavour of surprise:
from decimal import Decimal
print("this one looks like a bug and is not:")
print(" round(2.675, 2) ->", round(2.675, 2), " expected 2.68")
print()
print("the reason is that 2.675 is not really 2.675:")
print(" ", Decimal(2.675))
print()
print("it is stored slightly BELOW 2.675, so rounding down is correct.")
print()
print("the same thing with another value:")
print(" round(1.005, 2) ->", round(1.005, 2))
print(" ", Decimal(1.005))
print()
print("and the classic:")
print(" 0.1 + 0.2 ->", 0.1 + 0.2)
print(" f'{0.1 + 0.2:.2f}' ->", f"{0.1 + 0.2:.2f}", " formatting hides it")
Output:
this one looks like a bug and is not:
round(2.675, 2) -> 2.67 expected 2.68
the reason is that 2.675 is not really 2.675:
2.67499999999999982236431605997495353221893310546875
it is stored slightly BELOW 2.675, so rounding down is correct.
the same thing with another value:
round(1.005, 2) -> 1.0
1.00499999999999989341858963598497211933135986328125
and the classic:
0.1 + 0.2 -> 0.30000000000000004
f'{0.1 + 0.2:.2f}' -> 0.30 formatting hides it

2.675 is stored just below 2.675, so rounding down is correct.The number you typed is not the number stored. Binary floating point cannot represent 2.675 exactly, and the value it holds is very slightly smaller, so rounding down is the right answer for the value that actually exists.
Formatting hides this most of the time, which is why f"{0.1 + 0.2:.2f}" looks fine. The underlying value is unchanged. Checking for NaN covers another corner of float behaviour.
Using Decimal for money
When the rounding rule is part of the requirement, floats are the wrong type:
from decimal import Decimal, ROUND_HALF_UP
print("when the rounding rule matters, use Decimal with strings:")
print()
for text in ("2.675", "1.005", "2.5"):
value = Decimal(text).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(f" Decimal('{text}').quantize(Decimal('0.01'), ROUND_HALF_UP) -> {value}")
print()
print("note the quotes. Decimal('2.675') is exactly 2.675;")
print("Decimal(2.675) inherits the float's error:")
print(" ", Decimal(2.675))
print()
print("money arithmetic stays exact:")
total = Decimal("19.99") * 3
print(" Decimal('19.99') * 3 ->", total)
print(" as currency ->", total.quantize(Decimal("0.01")))
Output:
when the rounding rule matters, use Decimal with strings:
Decimal('2.675').quantize(Decimal('0.01'), ROUND_HALF_UP) -> 2.68
Decimal('1.005').quantize(Decimal('0.01'), ROUND_HALF_UP) -> 1.01
Decimal('2.5').quantize(Decimal('0.01'), ROUND_HALF_UP) -> 2.50
note the quotes. Decimal('2.675') is exactly 2.675;
Decimal(2.675) inherits the float's error:
2.67499999999999982236431605997495353221893310546875
money arithmetic stays exact:
Decimal('19.99') * 3 -> 59.97
as currency -> 59.97
Decimal stores numbers as written, so Decimal("2.675") really is 2.675 and ROUND_HALF_UP gives 2.68. The quotes matter: passing a float hands the error straight through.
quantize() sets the number of places and the rule together, which is what financial code needs. Currency arithmetic also stays exact rather than accumulating tiny errors.
Format numbers with thousands separators and alignment
The same spec hand