Under a megabyte on disk. 7.79 MB in memory. Same file, same 7,043 rows, and I had done nothing to it except call read_csv.
That ratio is the subject of this part. pandas will hand you a correct answer while using eight times more memory than it needs, running an operation three thousand times slower than its vectorised equivalent, and quietly changing your row count on a join. None of those surface as errors. They surface as a laptop that starts swapping, a training job that runs overnight instead of for an hour, and a model whose accuracy is wrong in a direction that flatters you. Past the tutorial level, most pandas skill is knowing which of your everyday habits are doing this to you.
TL;DR
Vectorised arithmetic beat a row by row loop by a factor of 2,907 on 704,300 rows in my test. Iterating a dataframe to build a column is the one habit I would remove first from any beginner codebase.
Casting 16 low cardinality text columns to category and downcasting the numerics took the churn frame from 7.79 MB to 1.04 MB, an 87 percent cut, with no change to any downstream result.
Pass validate to every merge you write. One argument, and it catches the join that turned 7,043 customers into 7,985 rows in the example below.
Copy on Write is the only mode in pandas 3.0, so chained assignment raises ChainedAssignmentError instead of failing in silence. That is an improvement, and it will break old notebooks.
Where the churn project stands
Part 3 left us with a project folder, an isolated environment and a lockfile, and nothing in it. This part puts data in. Across the rest of the series we build one thing end to end: a model that predicts which subscribers of a telecoms company are about to cancel, using the public Telco Customer Churn dataset that IBM publishes. Seven thousand and forty three customers, twenty one columns, one label saying whether each of them left in the last month.
By the end of this part the project can load that file correctly, hold it in a tenth of the memory, join it to a second table without corrupting the row count, and reshape it between the layout a human reads and the layout a model needs. Part 5 goes after the messier sources, APIs and SQL pulls. Part 6 turns these columns into features.
One point of vocabulary before the code, because I will use it constantly. Vectorisation means handing an entire array to a routine written in C rather than looping over its elements in Python. NumPy is the library that provides those arrays and routines; pandas is built directly on top of it, and a pandas Series is a NumPy array with a label index attached. When people say pandas is slow, they nearly always mean they wrote a Python loop around something NumPy could have done in one call.
Loading the churn table without silent damage
Start with the naive load and look hard at what came back. Reading a CSV is where most of the damage in a project is done, because pandas guesses a type for every column and never tells you when it guessed defensively.
Tested with pandas 2.3.3 and NumPy 2.2.6 on Python 3.10.12.
Two numeric columns and one that should be numeric and is not.
TotalCharges came back as object, which is pandas speaking for a column of arbitrary Python objects, in practice strings. MonthlyCharges next to it parsed as float64. Something in that column is not a number, and pandas fell back rather than complain. Casting it directly tells you what.
A single space character, not an empty string and not a NaN.
Eleven rows carry a lone space in TotalCharges, and every one of them has tenure equal to zero. Those are customers who signed up and had not yet been billed. Nothing is broken in the data; the file simply encodes a missing value as whitespace, which is a convention pandas cannot guess. Coercion turns them into proper missing values and lets you decide what to do next.
Eleven brand new customers, correctly identified rather than dropped by accident.
Two habits come out of this. Run dtypes on every fresh load and treat any unexpected object column as a defect to investigate, not a quirk to work around. And prefer pd.to_numeric with errors set to coerce over astype, because coerce marks the bad values instead of stopping at the first one. Deleting the eleven rows is also defensible, and it is what most published notebooks on this dataset do. I keep them, because tenure zero customers are exactly the population a retention team cares about and throwing them away at load time is a decision you will not remember making three parts later. For the wider treatment of missing data conventions, my analyst post on cleaning messy data covers the patterns that recur.
Vectorisation, and what a Python loop actually costs
Everybody repeats that you should not loop over a dataframe. Far fewer people have measured the gap, and the size of it changes how seriously you take the advice. Here is the same trivial calculation, monthly charge multiplied by tenure, written three ways over 704,300 rows, which is the churn file stacked a hundred times.
Absolute timings depend on your machine. Ratios of this magnitude do not.
Notice that apply with axis set to 1 gave almost no benefit over the explicit loop. That surprises people, because apply looks like a pandas method and therefore feels like it should be fast. It is not. Row wise apply constructs a Series object for every row and calls your Python function on it, which is a loop with extra allocation. Column wise apply, meaning axis set to 0, is a different animal and is usually fine.
Conditional logic is where beginners reach for apply most often, and it is the case with the cleanest vectorised replacement. Two branches call for numpy.where. More than two call for numpy.select, which takes a list of boolean conditions and a matching list of results.
numpy.select evaluates conditions in order and takes the first match, so ranges can overlap safely if you order them from narrowest to widest.
My rule, and I apply it without exception in review: if a line contains apply with axis equal to 1, it needs a comment explaining why the vectorised form was impossible. Perhaps one time in twenty there is a genuine reason, usually a call into a library that only accepts scalars. Every other time it is habit.
Memory footprint of a dataframe, measured
Memory matters earlier than people expect. Not because 7 MB is a problem, but because the habits you form on 7 MB are the habits you will still have on 7 GB, and by then the cost of changing them is a rewrite. Ask a dataframe what it weighs, with deep set to True so that the actual string contents are counted rather than just the pointers to them.
Your figures will land within a few hundred kilobytes of these, depending on interpreter build.
Categorical is the conversion that does the heavy lifting here. A column such as Contract holds three distinct labels repeated seven thousand times; as object dtype pandas stores seven thousand separate Python string objects. As category it stores three labels and a byte per row pointing at one of them. Downcasting numerics matters less on this file but scales the same way, and the table below is the reference I actually use.
| dtype | Holds | Bytes per row | Churn column that fits |
|---|---|---|---|
| int8 | minus 128 to 127 | 1 | SeniorCitizen |
| int16 | minus 32,768 to 32,767 | 2 | tenure, maximum 72 |
| float32 | about 7 significant digits | 4 | MonthlyCharges |
| category | a small fixed label set | 1, plus one copy of each label | Contract, PaymentMethod, InternetService |
| object | a Python string per row | 60 and upward | what read_csv gives you by default |
Merges that quietly change your row count
Churn work never stays inside one table. Support tickets, usage logs, billing events, marketing contacts, all of them arrive as separate extracts keyed on a customer id, and all of them have to be joined on. Joining is also where I have watched more silent corruption enter a model than anywhere else in the pipeline. If joins are new to you, my analyst post on SQL joins explains the shapes; pandas merge behaves the same way.
Here is a support ticket table where some customers appear several times and many appear not at all, which is what a real ticket extract looks like.
Ticket rows are sampled at random, so your exact count will sit near 7,985 rather than on it. Inflation is guaranteed; its size is not.
A left join promises to keep every row of the left frame. It does. What it does not promise is to keep only one copy of each. Any customer with three tickets now occupies three rows in a table you still think of as one row per customer. Every later count, mean and model weight is now skewed toward whoever complains most, and pandas has raised nothing.
War story
I lost most of a week to exactly this on a subscription client in 2023. Our churn baseline came back with an AUC of 0.86 against 0.79 the week before, and nobody questioned a jump that flattered us. Two sprints later a colleague asked why the customer count in my feature table was 118,402 when the warehouse said 104,771. A left join onto a ticket extract had duplicated the customers who raised tickets, and customers who raise tickets churn more, so the duplication had loaded the training set with churners. Real AUC was 0.78. Explaining that number to a steering committee that had already seen 0.86 cost more than the week of rework did. Since then I have never written a merge in production code without validate on it.
Prevention costs one keyword. merge takes a validate argument that states the cardinality you believe you have, and raises MergeError when reality disagrees.
An error at the moment the assumption breaks, rather than a wrong number three weeks later.
Correct handling is to aggregate the right hand table to one row per key first, then merge, then fill the customers who had no tickets at all.
Row count preserved, and zero now means no tickets rather than missing.
| Right hand table | how | validate | Rows out of 7,043 |
|---|---|---|---|
| several rows per customer | left | not passed | 7,985, silently wrong |
| several rows per customer | left | one_to_one | MergeError raised |
| aggregated to one row per customer | left | one_to_one | 7,043, correct |
| aggregated to one row per customer | inner | one_to_one | 3,058, matched only |
| deliberate row expansion | left | one_to_many | more than 7,043, on purpose |
Reshaping between wide and long
Churn data arrives wide, meaning one row per customer with a column for each add on service. Some questions are far easier to ask when the same data is long, meaning one row per customer per service. Counting how many extras a customer subscribes to is one of them, and it becomes a feature in Part 6.
melt takes you wide to long. pivot_table takes you back. Neither is hard once you see the shape change, which is why I draw it before writing either.
28,172 is exactly 7,043 times 4, which is the check worth running after any melt.
Notice the validate argument in that merge as well. The addons frame is produced by a groupby and therefore has one row per key by construction, so validate can never fire. I still write it, because the guarantee is cheap and because six months from now somebody will change the groupby into something that no longer guarantees it. Assertions that hold today are how you find out when they stop holding.
Copy on Write and chained assignment in pandas 3.0
Anybody who learned pandas before 2024 carries a scar from SettingWithCopyWarning, the message that appeared when you assigned into a slice and pandas could not tell whether you were editing the original or a copy. pandas 3.0 ends that ambiguity. Copy on Write became the default and only mode in the 3.0 release, and it makes one rule absolute: anything derived from a dataframe behaves as a copy, so you can only change values through the object itself.
Practically, chained assignment now raises rather than doing nothing.
Reproduced on pandas 2.3.3 with copy_on_write enabled, which matches pandas 3.0 behaviour.
Before Copy on Write that first line updated nothing and printed a warning most people had trained themselves to scroll past. I have debugged a pipeline where a fillna written that way had been a no operation for four months, and the model had been training on missing values the whole time without anybody noticing, because a missing value in a tree model is not an error. An exception is a much better outcome than a warning. If you are upgrading an older notebook to pandas 3.0, expect this to be the change that breaks it, and treat every break as a bug you already had.
Rules I apply to every pandas project now
Five of them, in the order I would teach them. Print dtypes immediately after every load and treat an unexpected object column as a defect. Never iterate rows to compute a column; reach for a NumPy expression, and for conditional logic reach for numpy.where or numpy.select rather than apply with axis equal to 1. Convert low cardinality text columns to category as part of loading, not as a later optimisation, so the habit is already there when the file is large enough to matter. Pass validate on every merge and assert the row count afterwards. Address rows and columns in a single .loc call rather than chaining two operations.
Two things I would specifically avoid. Do not use apply with axis set to 1 as a default tool; it reads like pandas and performs like a loop, and it was 2,756 times slower than the vectorised form in the measurement above. And do not use inplace on anything. It rarely saves memory, it returns None so it cannot be chained, and under Copy on Write its behaviour is confusing enough that the pandas team discourage it.
Part 5 goes after the data sources themselves: pulling from APIs with pagination and rate limits, reading SQL into pandas without dragging the whole table across the network, and the file formats that misbehave, chiefly Excel dates and CSV encodings. Then Part 6 takes the churn frame we now hold correctly in memory and builds features from it, which is where most real accuracy comes from. Before you move on, run the load and the safe merge from this part in your Part 3 environment and confirm you get 7,043 rows and 11 coerced values. If both numbers match, your foundation is sound.
References
- What is new in pandas 3.0.0, pandas documentation
- Copy-on-Write user guide, pandas documentation
- DataFrame.merge API reference, pandas documentation
- NumPy release notes
- Telco Customer Churn dataset, IBM on GitHub


DrJha