The rows list comes from the last section. Text "40.0" plus "25.0" is "40.025.0". Text also sorts by letters, so "100" comes before "9". Numbers sort by size.
Convert text to a number with int() or float(). Use int() for whole numbers, such as order_id. Use float() for numbers with a decimal point.
ValueError: invalid literal for int() with base 10: '40.0'
The last line is the error message, not normal output. int("40.0") fails with a ValueError, because the text has a decimal point. A ValueError means the value has the right type but cannot be converted.
You will use three number types in data work. Choose by what the number means:
Three number types
Type
Holds
Use it for
int
Whole numbers, exact
order_id, quantity, counts
float
Decimal numbers, approximate
Measurements, such as temperature
Decimal
Decimal numbers, the exact digits you typed
Money
Two sections later you will see why float is only approximate, and why money needs Decimal.
03 · Missing values
None, empty text and 0
⏱ 10 min · Topic 3 of 14
In SQL, NULL means a value is missing. In Python, the missing value is None. It has its own type, NoneType.
From a CSV file, a missing amount does not arrive as None. It arrives as the empty string "". Order 105 shows it.
These are three different facts. Python agrees: no two of them are equal.
Three ways to say "no amount"
Value
Meaning
Example in the shop
None
Missing, not known
Order 105: no amount yet
""
Empty text, a blank cell in the file
The raw cell of order 105
0
A real number: zero
A free order with amount 0
Convert the blank cell to None as soon as you read it. Then the rest of your code sees one kind of missing.
Here float is only a teaching tool. Money gets Decimal soon:
The first test (if a) drops 0.0 and None. The second test drops only None, so the free order stays.
Interview question
"What is the difference between if x: and if x is None:?" The first is False for 0, "", [] and None. The second is True only for None.
Watch out: 0 is not missing
A total that treats 0 as missing drops free orders. A total that treats None as 0 hides orders with no amount. Decide which one you mean, and write the rule in a comment.
04 · Numbers
Money needs Decimal
⏱ 7 min · Topic 4 of 14
A float keeps a number as 0s and 1s (binary). The number 0.1 cannot be written exactly with 0s and 1s, just as 1/3 cannot be written exactly with decimal digits. Python keeps the closest value.
Add 0.1 and 0.2, then compare the result to 0.3. Look at both answers:
Decimal(0.1) shows the exact value of the float. Decimal("15.50") keeps the last zero. Adding a whole number works. Adding a float, such as Decimal("1.5") + 1.5, raises a TypeError.
From CSV text, use Decimal(text) directly. The text never becomes a float, so no error enters. Write money back as text with str().
Interview question
"Why not store money in a float?" Many decimal numbers are only approximate in a float, so sums become slightly wrong. Use Decimal built from strings, or whole cents as int.
05 · Numbers
Rounding money
⏱ 6 min · Topic 5 of 14
A tie is a number exactly halfway between two choices, such as 2.5 between 2 and 3. Python's round() does not always round a tie up.
round() on a float sends a tie to the nearest even number (some books call this banker's rounding). It also reads the stored float, which may be slightly off:
0.5 goes to 0 and 2.5 goes to 2: both are ties, and 0 and 2 are even. 0.125 is exactly halfway between 0.12 and 0.13, so it goes to the even one, 0.12.
2.675 is stored a little below 2.675, so it gives 2.67. No rounding rule can fix that, because the stored value is already below the tie.
Shops and banks often want the school rule: a tie goes up. Use Decimal with quantize() and ROUND_HALF_UP. The first argument says how many places to keep:
Without the rounding argument, quantize() also sends a tie to even, so 0.125 gives 0.12. ROUND_HALF_UP goes away from zero, so -2.5 becomes -3.
Watch out: round once, at the end
Round each line and then add, and the total can differ by a cent from rounding the total. Pick one rule, write it down, and round at the end.
06 · Types
Convert with care
⏱ 7 min · Topic 6 of 14
To parse is to read text and turn it into a typed value. Real files have bad values: "N/A", extra spaces, letters in a number.
You parse once, at the point where data enters. This point is the boundary. A bad value must not crash the run, and it must not become a silent 0.
Send the bad row to a list of bad rows, with its line number and the reason. Money is Decimal, as in the last sections. Here is a messy copy of the amounts, and line 5 holds "N/A":
raw_rows is the list of dicts in the input table. Line 3 had spaces, and strip() fixed it. Line 6 is blank, so it becomes None. Only line 5 is bad.
Decimal raises InvalidOperation when text is not a number. We turn it into a ValueError, so one except line catches every kind of bad value. The !r in the message shows the value in quotes, so you can see spaces.
A blank amount is not a bad row here. It means "no amount yet", so it becomes None. Only text that is not a number goes to the bad list.
Now add up the amounts. Skip the None values first, because you cannot add None to a number. Then take the average:
Six rows were read: five good and one bad. Only four have an amount. The average is 205.0 / 4 = 51.25. Dividing by 6 gives 34.1666..., a wrong answer.
Watch out: Decimal accepts NaN
Decimal("NaN") is a valid value, and it breaks a total. After parsing, check value.is_finite().
07 · Counting
Check before you count
⏱ 4 min · Topic 7 of 14
To aggregate means to add up or count many rows into one number. Check the rows first, then count.
If you drop bad rows after you count, the numbers do not match. The average above divides by the four rows that have an amount. It does not divide by all six rows read.
Always print the counts next to the result. A reader can then check that the numbers add up:
Rows read must equal good plus bad. If it does not, a row got lost.
08 · Dates and times
Read dates and times
⏱ 5 min · Topic 8 of 14
Dates in files are text too. ISO 8601 is the standard text form: 2026-01-03 for a date, and 2026-01-03T10:00:00 for a date with a time. The T separates the two.
Use date.fromisoformat() for a date and datetime.fromisoformat() for a date with a time. Suppose order 101 was placed on 3 January, and order 106 on 21 January:
For other layouts, use strptime() and name the layout yourself. %d/%m/%Y means day, month, year. Always name the layout: 03/01/2026 is 3 January here and 1 March in the USA.
In SQL, a column of type DATE or TIMESTAMP does this for you. In Python, you do it by hand, at the boundary.
09 · Dates and times
Naive and aware times
⏱ 10 min · Topic 9 of 14
A naive datetime has no time zone. "10:00" could be London or Delhi. An aware datetime carries an offset from UTC, so it names one exact moment.
UTC is the world reference time. An offset says how far a clock is from UTC. +05:30 means 5 hours 30 minutes ahead of UTC. So 15:30+05:30 is 10:00 UTC. -04:00 means 4 hours behind.
Look at three shapes of timestamp text. Which ones name an exact moment?
Three shapes of timestamp
Text
Kind
tzinfo
Exact moment?
2026-01-03T10:00:00Z
aware
UTC
Yes
2026-01-03T15:30:00+05:30
aware
offset +05:30
Yes
2026-01-03T10:00:00
naive
None
No
Pipelines store every moment in UTC. Python 3.11 and newer read a final Z as UTC. Parse the three texts, which means read them into datetime values, and compare:
The Z text and the +05:30 text are the same moment, so a == b is True. The third text has no zone, so its tzinfo is None. It is naive.
Do not mix the two kinds. Ordering a naive time against an aware time raises a TypeError.
Equality does not raise. It silently says False:
Naive against awareworked example
Python
print(c==a)print(c<a)
Result · 2 rows
output
False
TypeError: can't compare offset-naive and offset-aware datetimes
c is naive and a is aware. The == gives a wrong False, even though both show 10:00. The < stops with an error. The last line is the error message, not normal output.
Now write one function that turns all three shapes into UTC. A naive time needs a decision.
Here we decide that naive means UTC, because we know our source. Write your decision down:
All three shapes become the same UTC moment. Text four is 23:30 at offset -04:00, so UTC is 03:30 on the next day. The call replace() adds a zone and keeps the clock. The call astimezone() moves the clock.
Optional: some sources send a zone name, not an offset. The offset of a zone name changes during the year. Use zoneinfo for these:
Both are 9:00 in New York. In June, clocks are 4 hours behind UTC. In January, 5 hours. A fixed offset cannot know that. A zone name can.
Watch out: astimezone() on a naive time
Calling astimezone() on a naive time assumes the clock of the machine that runs your code. The result changes from laptop to server. Add the zone with replace() first, as in to_utc.
10 · Dates and times
Half-open time ranges
⏱ 8 min · Topic 10 of 14
A range [start, end) includes start but not end. This is a half-open range.
The square bracket means "included". The round bracket means "not included". In Python, start <= t < end means start <= t and t < end.
The SQL essentials chapter taught the same rule: write >= start AND < end, not BETWEEN.
Look at five events. Two sit exactly on midnight: e3 at the start of 11 January and e5 at the start of 12 January:
The hint says int, but the text "40" ran anyway. Text times 2 repeats the text, so the answer is "4040".
Real checks run at the boundary and reject the row. The validation chapter shows how.
13 · Practice
Practice lab
⏱ 7 min · Topic 13 of 14
You learned to convert text, handle missing values, use Decimal for money, and convert times. Each exercise opens in the Python workspace with its own data.
Before you run each exercise, guess one thing that could go wrong. Then check your guess.