Last checked
Request the full statement, open the password-protected PDF with pdfplumber, load the table rows into pandas, drop failed and repeated header rows, turn the money columns into numbers, label each row with keyword rules, then group by category and plot. The code below does all of it on made-up rows.
McTaba runs coding and data bootcamps in Kenya. This guide is written to be useful whether or not you ever join one.
Your M-Pesa statement is the best practice dataset you already own. Every shilling you sent, paid or received is a row. This tutorial is one project in our data science career map for Kenya.
Request a full statement through the M-Pesa menu on *234# or the Safaricom self-care site. The statement arrives by email as a PDF (Kenyans.co.ke guide, checked on 3 November 2026).
The password is where guides disagree. The Kenyans.co.ke guide says it is the ID document registered on your line. A People Daily report on Safaricom's explanation says each request gets its own password by SMS (checked on 3 November 2026). Try the SMS code first, then your ID number.
Open your own PDF before writing code. Look for these seven columns: receipt number, completion time, details, status, paid in, withdrawn and balance. Names and order can change, so match them to what you see.
The file holds your name, your phone number and the numbers of everyone you paid. Keep it on your laptop. Never upload it to a free online converter. Never commit it to GitHub: add *.pdf to .gitignore. If you share a chart, share the totals, not the rows.
Install the tools with pip install pdfplumber pandas matplotlib. The code below reads every table on every page. The rows in this tutorial are made up, so your totals will differ.
import pdfplumber
import pandas as pd
PASSWORD = "12345678" # the SMS code, or your ID number
rows = []
with pdfplumber.open("statement.pdf", password=PASSWORD) as pdf:
for page in pdf.pages:
for table in page.extract_tables():
rows.extend(table)
cols = ["receipt", "time", "details", "status",
"paid_in", "withdrawn", "balance"]
df = pd.DataFrame(rows, columns=cols)
print(df.head())
If extract_tables() returns nothing, your statement may not have ruled lines. Try page.extract_table(table_settings={"vertical_strategy": "text", "horizontal_strategy": "text"}), or the camelot library, which has a "stream" mode for tables without lines.
| Problem you will see | Cause | Fix |
|---|---|---|
| Header words inside the data | The header repeats on each page | Drop rows where receipt equals the header text |
| Amounts read as text | Commas, such as 20,000.00 | Strip commas, then pd.to_numeric |
| Withdrawals look negative | The withdrawn column carries a minus sign | Use abs() on the column |
| Money that never moved | Failed and reversed rows | Keep only completed rows |
These are the problems we ran into on a made-up statement. Real statements may add more, so print df["status"].unique() and read what is there.
The same cleaning steps are covered in more depth in our pandas tutorial on a Nairobi shop's sales. Our Data Science to AI Engineering programme covers pandas in phase 2.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
df = df[df["receipt"] != "Receipt No."]
df = df[df["status"].str.upper() == "COMPLETED"]
df["time"] = pd.to_datetime(df["time"])
for c in ["paid_in", "withdrawn", "balance"]:
df[c] = pd.to_numeric(
df[c].str.replace(",", ""), errors="coerce"
).fillna(0)
df["spent"] = df["withdrawn"].abs()
RULES = {
"Airtime and data": ["SAFARICOM", "AIRTIME", "BUNDLES"],
"Electricity": ["KPLC"],
"Groceries": ["NAIVAS", "QUICKMART", "CARREFOUR"],
"Eating out": ["JAVA", "KFC", "CAFE"],
"Cash out": ["WITHDRAWAL"],
"Sent to people": ["CUSTOMER TRANSFER"],
}
def categorise(text):
text = text.upper()
for name, words in RULES.items():
if any(w in text for w in words):
return name
return "Other"
df["category"] = df["details"].apply(categorise)
The details column is free text: a till name, a paybill number, a person's name. A keyword table is a fair start. Print df[df.category == "Other"]["details"] and add words until "Other" is small.
Rules go first-match-wins, so put the narrow ones above the wide ones. "SAFARICOM" sits above "CUSTOMER TRANSFER" for a reason: a bundle purchase must not land in "Sent to people".
import matplotlib.pyplot as plt
spend = df[df["spent"] > 0]
october = spend[(spend["time"] >= "2026-10-01")
& (spend["time"] < "2026-11-01")]
by_cat = (october.groupby("category")["spent"]
.sum().sort_values())
print(by_cat)
by_cat.plot.barh(color="#2a6f4e")
plt.xlabel("KES spent")
plt.title("Spending by category, October")
plt.tight_layout()
plt.savefig("october.png")
A sorted horizontal bar reads faster than a pie. Look at the longest bar first and ask whether it matches how you think you spend. Most people are surprised by "Cash out" and "Sent to people".
Next, try a daily line for paid_in against spent with october.groupby(october["time"].dt.date). Month-end dips show up quickly. Turning that into a story for someone else is the skill in our data storytelling guide.
The same table can feed a budget alert, or a score of how steadily money comes in. The chama version of that idea is in our chama credit score tutorial. If you handle other people's statements, read how to anonymise personal data in Kenya first.
Want this skill with a mentor and a capstone? Our Data Science to AI Engineering programme starts 11 January 2027 at KES 140,000, with 5 months full time or 8 months part time.
KES 140,000
Data Science to AI Engineering: 5 months full time or 8 months part time, hybrid in Nairobi. Fraud, credit and M-Pesa data from Week 1, and every model deployed. Starts 11 January 2027.
See the Data Science programme