Python · Beginner
Python strings and numbers: f-strings, slicing, rounding
Why it matters
Most data that reaches you was typed by a person: "Rs. 1,20,000", "+91-98765-43213", " 85000 ". Python string methods and careful number handling are how you turn that into values a report or a database can use. Analyst and data engineering job descriptions call this "data cleaning", and screening tests check it with questions like "why is 0.1 + 0.2 not 0.3?" and "check if a string is a palindrome".
By the end of this lesson you will clean a list of scholarship form entries into exact rupee amounts and standard phone numbers, and print an aligned report.
The concept
Integers and floats behave differently
int has arbitrary precision: it grows as large as memory allows, so it never overflows.
float is a 64-bit IEEE 754 binary number. It holds about 15 to 17 significant decimal digits, and most decimal fractions (0.1, 0.2) cannot be stored exactly.
import math
from decimal import Decimal, ROUND_HALF_UP
print(2 ** 100)
print(0.1 + 0.2, 0.1 + 0.2 == 0.3, math.isclose(0.1 + 0.2, 0.3))
print(round(2.5), round(3.5), round(0.125, 2), round(2.675, 2))
print(Decimal("2.675").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
print(Decimal(2.675))
print(Decimal("0.1") + Decimal("0.2"))
print(-7 // 2, -7 % 2, 7 // -2, int(-7 / 2), math.fmod(-7, 2))1267650600228229401496703205376
0.30000000000000004 False True
2 4 0.12 2.67
2.68
2.67499999999999982236431605997495353221893310546875
0.3
-4 1 -4 -3 -1.0Line by line, this is what each result teaches:
2 ** 100is exact. No overflow, ever, forint.0.1 + 0.2is not exactly0.3. Compare floats withmath.isclose, never==.round()uses banker's rounding (round half to even):round(2.5)is2andround(3.5)is4. This avoids a bias when you round many values, but it surprises everyone the first time. Andround(2.675, 2)gives2.67for a different reason: the float2.675is really2.67499999...(line 5 shows the exact stored value).For money, use
decimal.Decimalbuilt from a string, andquantizeto a fixed number of places with the rounding rule you choose.ROUND_HALF_UPis the "school" rule most invoices expect.Decimal(2.675)built from a float inherits the float's error. Always writeDecimal("2.675").Decimal("0.1") + Decimal("0.2")is exactly0.3.//is floor division: it rounds towards minus infinity, so-7 // 2is-4, not-3.%follows the same rule, so-7 % 2is1(the result takes the sign of the divisor).int(-7 / 2)truncates towards zero and gives-3. If you split a negative amount of minutes into hours and minutes, this difference matters.
Strings are immutable
You cannot change a character in place; every string method returns a new string:
name = "Palmer Penguins"
name[0] = "p"Traceback (most recent call last):
File "/home/asha/strings/immut.py", line 2, in <module>
name[0] = "p"
~~~~^^^
TypeError: 'str' object does not support item assignmentSo text.strip() on its own line does nothing useful: you must write text = text.strip().
Because strings are immutable, building a big string with += in a loop can copy the growing string again and again. CPython has an optimisation that often avoids the copy, so the real cost varies, but str.join is the reliable, idiomatic way:
import timeit
parts = [str(i) for i in range(100_000)]
def with_plus():
out = ""
for p in parts:
out += p + ","
return out
def with_join():
return ",".join(parts) + ","
assert with_plus() == with_join()
print(f"+= loop: {timeit.timeit(with_plus, number=20):.3f} s")
print(f"join : {timeit.timeit(with_join, number=20):.3f} s")+= loop: 0.123 s
join : 0.028 sAbout four times faster here, and it does not depend on an implementation detail. Collect pieces in a list, then "".join(pieces) once.
Slicing
text[start:stop:step] takes characters from start up to but not including stop. Negative numbers count from the end.
name = "Palmer Penguins"
print(name[0], name[-1], name[:6], name[-8:], name[::-1], name[::2])
print(repr(name[100:200]))
name[100]P s Palmer Penguins sniugneP remlaP Ple egis
''
Traceback (most recent call last):
File "/home/asha/strings/slice.py", line 4, in <module>
name[100]
~~~~^^^^^
IndexError: string index out of rangeA slice out of range quietly returns what exists (here, an empty string), while indexing a single position out of range raises IndexError. That makes slices safe for "first 10 characters" even on short strings, and it also means a wrong slice fails silently. [::-1] reverses any string or list.
The string methods you will use every day
raw = " Rs. 1,20,000 \n"
print(repr(raw.strip()), raw.strip().replace("Rs.", "").replace(",", "").strip())
print("a,b,,c".split(","), " a b\t c\n".split(), "a b".split(" "))
print("2024-25".split("-", 1), "x=1=2".partition("="))
print("Straße".lower(), "Straße".casefold(), "STRASSE".casefold() == "Straße".casefold())
print("report_2024.csv".startswith(("report", "summary")), "photo.JPG".lower().endswith((".jpg", ".png")))'Rs. 1,20,000' 120000
['a', 'b', '', 'c'] ['a', 'b', 'c'] ['a', '', 'b']
['2024', '25'] ('x', '=', '1=2')
straße strasse True
True True| method | what to remember |
|---|---|
strip(), lstrip(), rstrip() | remove whitespace (spaces, tabs, newlines) from the ends, never the middle |
split(",") | with an argument, every separator splits, so ",," makes an empty field |
split() | with no argument, splits on any run of whitespace and drops empty strings; usually what you want for words |
split("-", 1) / partition("=") | split only once; partition always returns three parts |
replace(old, new) | replaces every occurrence; chain calls to remove several characters |
startswith / endswith | accept a tuple of options; normalise case first for file extensions |
lower() vs casefold() | casefold is the stronger version for case-insensitive comparison ("ß" becomes "ss"); use it for matching, lower for display |
removeprefix / removesuffix (3.9+) | remove an exact prefix or suffix once; strip("Rs.") would remove those characters from both ends instead |
f-string format specs
from datetime import date
fee = 1234567.891
pass_rate = 0.8734
roll = 42
due = date(2026, 7, 31)
print(f"{fee:,.2f}") # thousands separator, 2 decimals
print(f"{fee:_.0f}") # underscore separator
print(f"{pass_rate:.1%}") # percentage
print(f"{roll:05d}") # zero padding
print(f"[{'CSE':^9}]") # centre in 9 characters
print(f"{due:%d-%m-%Y}", f"{due:%d %b %Y}") # dates use strftime codes
print(f"{fee=}", f"{pass_rate * 100 = :.1f}") # = shows the expression too1,234,567.89
1_234_568
87.3%
00042
[ CSE ]
31-07-2026 31 Jul 2026
fee=1234567.891 pass_rate * 100 = 87.3,groups in thousands (international style). It does not do Indian lakh and crore grouping; the worked example writes a small function for that.%multiplies by 100 and adds the sign, so pass it the fraction (0.8734), not87.34.Dates accept the same codes as
strftimeafter the colon.The
=specifier (3.8+) prints the expression and its value: perfect for quick debugging.
Unicode, bytes and len()
A Python str is a sequence of Unicode code points. Text on disk or on the network is bytes, and an encoding (almost always UTF-8) converts between them.
word = "नमस्ते"
print(word, len(word), [hex(ord(ch)) for ch in word])
data = word.encode("utf-8")
print(data, len(data))
print(data.decode("utf-8"))
print("₹".encode("utf-8"), len("₹"), len("₹".encode("utf-8")))
data.decode("ascii")नमस्ते 6 ['0x928', '0x92e', '0x938', '0x94d', '0x924', '0x947']
b'\xe0\xa4\xa8\xe0\xa4\xae\xe0\xa4\xb8\xe0\xa5\x8d\xe0\xa4\xa4\xe0\xa5\x87' 18
नमस्ते
b'\xe2\x82\xb9' 1 3
Traceback (most recent call last):
File "/home/asha/strings/unicode_demo.py", line 7, in <module>
data.decode("ascii")
~~~~~~~~~~~^^^^^^^^^
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe0 in position 0: ordinal not in range(128)"नमस्ते" looks like four letters to a reader, len() says 6 (vowel signs and the virama are separate code points), and in UTF-8 it takes 18 bytes. So:
Do not use
len()to enforce "maximum characters a user sees" on Indian-language text without thinking about it, and never use it to compute byte sizes; encode first.A database column of "50 characters" might mean 50 bytes. Check.
UnicodeDecodeErrormeans you decoded bytes with the wrong encoding. Passencoding="utf-8"whenever you open text files.
Converting text to numbers
for text in ["12", "0012", " 7 ", "12.5", "1,200", "१२"]:
try:
print(repr(text), "->", int(text))
except ValueError as e:
print(repr(text), "-> ValueError:", e)
print(int(float("12.5")), int(float("-12.5")), round(float("12.5")))'12' -> 12
'0012' -> 12
' 7 ' -> 7
'12.5' -> ValueError: invalid literal for int() with base 10: '12.5'
'1,200' -> ValueError: invalid literal for int() with base 10: '1,200'
'१२' -> 12
12 -12 12int()ignores surrounding whitespace and leading zeros, so"0012"becomes12. That is wrong for PIN codes, roll numbers and phone numbers: keep identifiers as strings.int("12.5")raises;int(float("12.5"))truncates towards zero;round()applies banker's rounding (12.5 rounds to 12).Commas are not understood. Remove them first.
int()accepts digits from other scripts, including Devanagari. Handy, and a reason to validate withch in "0123456789"instead ofch.isdigit()when you need ASCII digits only.
Worked example: cleaning a scholarship form
Six students typed their family income and phone number into free-text boxes. The task: turn each income into an exact amount in rupees, normalise each phone number to +91 98765 43210, flag anything that cannot be cleaned, and print a readable report. (The names and numbers are made up; the formats are the ones you will see in real form exports.)
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
# What students typed into a scholarship form's "family income" and "phone" boxes.
entries = [
("Ananya", "Rs. 1,20,000", "+91 98765 43210"),
("Rohit", "₹2,50,000.50", "098765-43211"),
("Meera", " 85000 ", "9876543212"),
("Kabir", "INR 3.5 lakh", "+91-98765-43213"),
("Sana", "1,00,00,000", "91 98765 43214"),
("Dev", "Rs 12,345.675", "98765 4321"),
]
PREFIXES = ("rs.", "rs", "inr", "₹")
def parse_rupees(text):
"""'Rs. 1,20,000' -> Decimal('120000.00'). Understands 'lakh' and 'crore'. None if unreadable."""
cleaned = text.strip().lower()
for prefix in PREFIXES:
if cleaned.startswith(prefix):
cleaned = cleaned[len(prefix):].strip()
break
multiplier = Decimal(1)
if cleaned.endswith("lakh"):
multiplier, cleaned = Decimal(100_000), cleaned.removesuffix("lakh").strip()
elif cleaned.endswith("crore"):
multiplier, cleaned = Decimal(10_000_000), cleaned.removesuffix("crore").strip()
try:
amount = Decimal(cleaned.replace(",", "")) * multiplier
except InvalidOperation:
return None
return amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def indian_format(amount):
"""Group digits the Indian way: 1,20,000.00 and 1,00,00,000.00."""
rupees, paise = f"{amount:.2f}".split(".")
if len(rupees) > 3:
head, tail = rupees[:-3], rupees[-3:]
groups = []
while len(head) > 2:
groups.insert(0, head[-2:])
head = head[:-2]
groups.insert(0, head)
rupees = ",".join(groups) + "," + tail
return f"₹{rupees}.{paise}"
def normalise_phone(text):
"""Return '+91 98765 43210' for a valid Indian mobile number, else None."""
digits = "".join(ch for ch in text if ch.isdigit())
if len(digits) == 12 and digits.startswith("91"):
digits = digits[2:]
elif len(digits) == 11 and digits.startswith("0"):
digits = digits[1:]
if len(digits) != 10 or digits[0] not in "6789":
return None
return f"+91 {digits[:5]} {digits[5:]}"
print(f"{'name':<8}{'income (raw)':>16} {'income':>16} {'phone':<16}")
print("-" * 60)
for name, income_raw, phone_raw in entries:
amount = parse_rupees(income_raw)
income = indian_format(amount) if amount is not None else "CHECK"
phone = normalise_phone(phone_raw) or f"CHECK {phone_raw!r}"
print(f"{name:<8}{income_raw.strip():>16} {income:>16} {phone:<16}")name income (raw) income phone
------------------------------------------------------------
Ananya Rs. 1,20,000 ₹1,20,000.00 +91 98765 43210
Rohit ₹2,50,000.50 ₹2,50,000.50 +91 98765 43211
Meera 85000 ₹85,000.00 +91 98765 43212
Kabir INR 3.5 lakh ₹3,50,000.00 +91 98765 43213
Sana 1,00,00,000 ₹1,00,00,000.00 +91 98765 43214
Dev Rs 12,345.675 ₹12,345.68 CHECK '98765 4321'What to notice:
Prefix order matters.
"rs."is checked before"rs", otherwise"Rs. 1,20,000"would leave a stray.and fail to parse.Decimalall the way."3.5"times100000is exactly350000, and12,345.675rounds half-up to12,345.68. With floats,round(12345.675, 2)could go either way depending on the binary representation.Phones are kept as strings. Stripping everything that is not a digit handles spaces, dashes and the
+, then the length and first digit decide validity (Indian mobile numbers are 10 digits starting with 6 to 9). Dev's number has nine digits, so it is flagged rather than "fixed".Bad data is flagged, not dropped. The report says
CHECKwith the original text, so a person can follow up.Indian grouping is your own function.
f"{x:,}"would print12,000,000.00for Sana, which an Indian reader would misread.
Common mistakes
Using float for money.
round(2.675, 2)gives2.67. UseDecimal("2.675")(from a string) andquantize.Expecting
round(2.5)to be 3. Python rounds halves to even. UseDecimalwithROUND_HALF_UPwhen a rule says otherwise.Forgetting to assign the result of a string method.
name.strip()alone changes nothing; strings are immutable.text.strip("Rs.")to remove a prefix. It strips any of the charactersR,sand.from both ends. Useremoveprefixorstartswithplus slicing.split(" ")on messy text. Double spaces produce empty strings. Usesplit()with no argument for whitespace.Converting identifiers to int. PIN codes, phone numbers and IDs like
"0012"lose leading zeros. Keep them as strings.Mixing up
//with truncation.-7 // 2is-4. Useint(a / b)ormath.truncif you want rounding towards zero.Opening files without
encoding="utf-8". Hindi, Tamil or the rupee sign then breaks on Windows withUnicodeDecodeError.
Interview angle
:::toggle{title="Why is 0.1 + 0.2 not equal to 0.3, and how would you store money?"} Floats are binary fractions, and 0.1 and 0.2 have no exact binary representation, so their sum is 0.30000000000000004. Compare floats with math.isclose. For money, use decimal.Decimal created from strings and quantize to two places with an explicit rounding rule, or store integer paise. In a database, use a NUMERIC/DECIMAL column, never FLOAT. :::
:::toggle{title="Reverse a string and check if it is a palindrome, ignoring case and punctuation."}
def is_palindrome(text):
letters = [ch.casefold() for ch in text if ch.isalnum()]
return letters == letters[::-1]
print(is_palindrome("Never odd or even"), is_palindrome("A man, a plan, a canal: Panama!"), is_palindrome("Python"))
print("Malayalam"[::-1])True True False
malayalaMExplain the steps: keep only letters and digits, normalise case with casefold, compare with the reversed slice. O(n) time and O(n) extra space; a two-pointer version uses O(1) extra space. :::
:::toggle{title="What does round(2.5) return?"} 2. Python 3 uses round-half-to-even (banker's rounding) so that rounding many values does not drift upwards. round(3.5) is 4. :::
Practice task
Extend clean_form.py.
Add three more entries:
"1.2 crore","Rs. twelve thousand"and"₹ 45,000/-". Make the first and third parse correctly and the second printCHECK.Add a
totalline under the report with the sum of all valid incomes, formatted withindian_format.Print each name padded to 8 characters and each income right-aligned to 16, and check the columns still line up when a name has Hindi characters such as
"अनन्या". Explain what you see.
Done when: the report shows 9 rows, ₹1,20,00,000.00 for the crore entry, ₹45,000.00 for the /- entry, a CHECK for the text amount, and a correct total.
:::toggle{title="Show hints"}
For
/-, usecleaned.removesuffix("/-")before parsing, and strip the space after₹.Sum the
Decimalvalues you got back, skippingNone.sum(amounts, Decimal(0))keeps the result aDecimal.Hindi names misalign because padding counts code points (
len("अनन्या")is 6) while the terminal draws fewer visible columns. Fixing it properly needs a library such aswcwidth; for reports, put such names in the last column. :::
Further reading
Built-in types: text sequence type str: every string method, with exact behaviour.
Format specification mini-language: all the f-string specs.
decimal module:
quantize, contexts and rounding modes.Floating-point arithmetic: issues and limitations: the official explanation of 0.1 + 0.2.
Unicode HOWTO: code points, encodings and decoding errors.