Data AnalyticsPractical workplace data tasks

Categorise UPI bank statement narrations with a rules table

PK
Pankit Kumar
Sr. Data Scientist at Parexel (a Goldman Sachs–backed company) · 27 September 2026 · 5 min read
In this article (5 sections)

Related programme: the Data Analytics with Generative AI course (3–4 months; ₹90,000 (inclusive of GST) · EMI from ₹4,000/month).

The short answer

To categorise a bank statement, first split each UPI narration into its parts (direction, reference, name, bank, VPA, note) with one regular expression, then apply an ordered rules table that matches patterns in a chosen field and assigns a category. Anything no rule matches stays "Uncategorised" and goes onto a review list sorted by amount, which is where the next rules come from.

Why the narration is worth parsing first

A narration like UPI/DR/624411223344/SWIGGY LIMITED/YESB/swiggy.food@ybl/Payment looks like one string, but it is six fields joined by slashes. Matching "SWIGGY" anywhere in the raw text works for a while, then fails on the day a person called Swiggy-something sends you money, or a note says "swiggy refund". Split first, then match on the field that actually identifies the merchant: usually the VPA.

Layouts differ between banks and even between a bank's account types. Some use hyphens, some put the name after the handle, some truncate long names. Open a real statement, copy twenty narrations of each type (UPI, NEFT, IMPS, ATM, card) into a text file, and write your pattern against those. The pattern below fits the synthetic layout in this article and nothing more.

Build the rules as a table, not as if-else code

Finance teams can read and correct a table. They cannot review forty nested if statements. Each rule has a priority, the field it looks at, a pattern and the category it assigns. The first rule to match wins, so specific rules take low priority numbers.

PriorityFieldPatternCategory
10narration^NEFT/.*SALARYSalary
20vpastarts with swiggy or zomatoFood delivery
20vpastarts with iocl, bpcl or hpclFuel
30note\brent\bRent
40narration^ATMCash withdrawal

In practice you keep this table in a sheet called rules, and the script reads it. Here it is inline so the example runs on its own.

Parsing narrations and applying the rules

All data below is synthetic. Merchant handles are made up to look realistic.

python
import pandas as pd

stmt = pd.DataFrame({
    "date": ["01/09/2026", "01/09/2026", "02/09/2026", "03/09/2026", "05/09/2026",
             "05/09/2026", "07/09/2026", "08/09/2026", "10/09/2026", "12/09/2026"],
    "narration": [
        "UPI/DR/624411223344/SWIGGY LIMITED/YESB/swiggy.food@ybl/Payment",
        "UPI/DR/624411229901/Ramesh Kumar/SBIN/ramesh.k55@oksbi/rent sept",
        "UPI/CR/624500118822/ACME SERVICES/HDFC/acmepay@hdfcbank/salary adv",
        "NEFT/N246260012345/ACME SERVICES PVT LTD/SALARY SEP26",
        "UPI/DR/624700551234/INDIAN OIL PETROL/ICIC/iocl.4412@icici/fuel",
        "UPI/DR/624700559876/BESCOM/UTIB/bescom.billdesk@axisbank/electricity",
        "UPI/DR/624900113355/Zepto/YESB/zepto.pay@ybl/grocery",
        "ATM WDL/NOIDA SEC 18/000123",
        "UPI/DR/625100442211/Priya Sharma/HDFC/priya.s@okhdfcbank/dinner split",
        "UPI/DR/625300990011/UNKNOWN MERCHANT 2231/PUNB/q2231@paytm/UPI",
    ],
    "debit": [438.0, 18000.0, None, None, 2500.0, 1860.0, 712.0, 5000.0, 650.0, 120.0],
    "credit": [None, None, 5000.0, 62000.0, None, None, None, None, None, None],
})

rules = pd.DataFrame([
    (10, "narration", r"^NEFT/.*SALARY", "Salary"),
    (20, "vpa", r"^(?:swiggy|zomato)", "Food delivery"),
    (20, "vpa", r"^(?:zepto|blinkit|bigbasket)", "Groceries"),
    (20, "vpa", r"^(?:iocl|bpcl|hpcl)", "Fuel"),
    (20, "vpa", r"(?:bescom|billdesk)", "Utilities"),
    (30, "note", r"\brent\b", "Rent"),
    (40, "narration", r"^ATM", "Cash withdrawal"),
], columns=["priority", "field", "pattern", "category"]).sort_values("priority", kind="stable")

parts = stmt["narration"].str.extract(
    r"^UPI/(?P<direction>DR|CR)/(?P<ref>\d{12})/(?P<name>[^/]*)/"
    r"(?P<bank>[^/]*)/(?P<vpa>[^/]*)/(?P<note>.*)$")
stmt = stmt.join(parts)
for col in ("vpa", "note", "name"):
    stmt[col] = stmt[col].fillna("").str.lower()

stmt["category"] = "Uncategorised"
stmt["rule"] = ""
for r in rules.itertuples():
    open_rows = stmt["category"].eq("Uncategorised")
    hit = open_rows & stmt[r.field].str.contains(r.pattern, case=False, regex=True)
    stmt.loc[hit, ["category", "rule"]] = [r.category, f"{r.field}~{r.pattern}"]

print(stmt[["date", "name", "vpa", "debit", "credit", "category"]].to_string(index=False))
print(stmt.groupby("category")[["debit", "credit"]].sum())

todo = stmt[stmt["category"].eq("Uncategorised")].sort_values("debit", ascending=False)
print("To review:", todo[["name", "vpa", "debit", "credit", "note"]].to_dict("records"))

# Control totals: categorisation must not lose or double any rupee.
assert stmt["debit"].sum() == 29280.0 and stmt["credit"].sum() == 67000.0

Seven of ten lines get a category. The three left over are exactly the ones a rule should not guess: a ₹650 payment to a person with the note "dinner split", a ₹120 payment to a merchant handle nobody recognises, and a ₹5,000 credit from the employer marked "salary adv", which is an advance to be repaid, not salary.

Details that decide whether the output can be trusted

The `rule` column. Every categorised row records which rule tagged it. When someone disputes a category, you can see the reason in one glance and fix the rule instead of the row.

First match wins. The loop only touches rows still uncategorised, so a lower-priority rule can never overwrite a higher one. Without that, the order in which rules happen to be listed would silently change results.

Word boundaries. \brent\b matches "rent sept" but not "current" or "parent". Loose patterns are the most common cause of confident wrong answers.

Control totals. The assert at the end checks that total debits and credits after categorisation equal the statement's totals. If you ever add a rule that duplicates rows, for instance by merging a vendor list with repeated names, this line stops you.

Growing the rules without losing control

Run the script each month and read the review list from the top. For each large uncategorised line, decide whether it is a one-off (categorise it by hand in a separate override sheet keyed on the reference number) or recurring (add a rule). After two or three months, most statements settle at a small uncategorised share, and the remaining lines genuinely need a human.

Keep person-to-person payments out of automatic rules unless you have a vendor or employee list to match against. A name like "Ramesh Kumar" tells you nothing about why the money moved; the note did, in this example, only because someone typed "rent".

If your statements arrive as PDFs, get them into a table first and check the row count against the statement's transaction count before categorising. Bank data like this also feeds reconciliation work, for which see match UTRs and cheque numbers to the cash book. Rule tables, regular expressions and control totals are the kind of dependable automation we teach before any AI tooling in our Data Analyst course.

Frequently asked questions

Can I categorise bank transactions from the narration alone?

Mostly, yes. The UPI handle (VPA) and the payer's note carry most of the signal, and a short rules table catches the recurring merchants. Person-to-person payments need a human decision or an extra lookup such as a vendor list, because the narration only shows a name.

Should I use AI to categorise bank statements?

Start with explicit rules, because they are reviewable and give the same answer every time. If you later add a model for the leftovers, keep its suggestions in a separate column for a person to accept, and keep the rules as the first pass.

PK
Pankit Kumar
Lead Instructor, NeuraPath Academy

Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.

This article is part of our Data Analytics with Generative AI programme — 3–4 months. The full analyst stack — Excel, SQL, Power BI and Python pipelines — then a generative-AI layer you can prove is right.

Explore Data Analytics with Generative AI
Counselling is free · no obligation

Not sure which programme fits?

Tell us your background and we will map it to the right entry point — including saying so when a cheaper programme is the better fit. A counsellor replies within one working day.