Python · Beginner

Python strings and numbers: f-strings, slicing, rounding

11 min read

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.

numbers_demo.py
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))
text
1267650600228229401496703205376
0.30000000000000004 False True
2 4 0.12 2.67
2.68
2.67499999999999982236431605997495353221893310546875
0.3
-4 1 -4 -3 -1.0

Line by line, this is what each result teaches:

  1. 2 ** 100 is exact. No overflow, ever, for int.

  2. 0.1 + 0.2 is not exactly 0.3. Compare floats with math.isclose, never ==.

  3. round() uses banker's rounding (round half to even): round(2.5) is 2 and round(3.5) is 4. This avoids a bias when you round many values, but it surprises everyone the first time. And round(2.675, 2) gives 2.67 for a different reason: the float 2.675 is really 2.67499999... (line 5 shows the exact stored value).

  4. For money, use decimal.Decimal built from a string, and quantize to a fixed number of places with the rounding rule you choose. ROUND_HALF_UP is the "school" rule most invoices expect.

  5. Decimal(2.675) built from a float inherits the float's error. Always write Decimal("2.675").

  6. Decimal("0.1") + Decimal("0.2") is exactly 0.3.

  7. // is floor division: it rounds towards minus infinity, so -7 // 2 is -4, not -3. % follows the same rule, so -7 % 2 is 1 (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:

immut.py
name = "Palmer Penguins"
name[0] = "p"
text
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 assignment

So 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:

join_timing.py
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")
text
+= loop: 0.123 s
join   : 0.028 s

About 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.

slice.py
name = "Palmer Penguins"
print(name[0], name[-1], name[:6], name[-8:], name[::-1], name[::2])
print(repr(name[100:200]))
name[100]
text
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 range

A 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

strings_demo.py
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")))
text
'Rs. 1,20,000' 120000
['a', 'b', '', 'c'] ['a', 'b', 'c'] ['a', '', 'b']
['2024', '25'] ('x', '=', '1=2')
straße strasse True
True True
methodwhat 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 / endswithaccept 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

fstrings_demo.py
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 too
text
1,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), not 87.34.

  • Dates accept the same codes as strftime after 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.

unicode_demo.py
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")
text
नमस्ते 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.

  • UnicodeDecodeError means you decoded bytes with the wrong encoding. Pass encoding="utf-8" whenever you open text files.

Converting text to numbers

python
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")))
text
'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 12
  • int() ignores surrounding whitespace and leading zeros, so "0012" becomes 12. 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 with ch in "0123456789" instead of ch.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.)

clean_form.py
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}")
text
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.

  • Decimal all the way. "3.5" times 100000 is exactly 350000, and 12,345.675 rounds half-up to 12,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 CHECK with the original text, so a person can follow up.

  • Indian grouping is your own function. f"{x:,}" would print 12,000,000.00 for Sana, which an Indian reader would misread.

Common mistakes

  • Using float for money. round(2.675, 2) gives 2.67. Use Decimal("2.675") (from a string) and quantize.

  • Expecting round(2.5) to be 3. Python rounds halves to even. Use Decimal with ROUND_HALF_UP when 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 characters R, s and . from both ends. Use removeprefix or startswith plus slicing.

  • split(" ") on messy text. Double spaces produce empty strings. Use split() 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 // 2 is -4. Use int(a / b) or math.trunc if you want rounding towards zero.

  • Opening files without encoding="utf-8". Hindi, Tamil or the rupee sign then breaks on Windows with UnicodeDecodeError.

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."}

python
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])
text
True True False
malayalaM

Explain 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.

  1. Add three more entries: "1.2 crore", "Rs. twelve thousand" and "₹ 45,000/-". Make the first and third parse correctly and the second print CHECK.

  2. Add a total line under the report with the sum of all valid incomes, formatted with indian_format.

  3. 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 /-, use cleaned.removesuffix("/-") before parsing, and strip the space after ₹.

  • Sum the Decimal values you got back, skipping None. sum(amounts, Decimal(0)) keeps the result a Decimal.

  • 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 as wcwidth; for reports, put such names in the last column. :::

Further reading