DEV Community

Archit Mittal
Archit Mittal

Posted on Originally published at architmittal.com

Build an Offline GSTIN Validator in 62 Lines of Python

Every vendor master I have ever been handed has bad GSTINs in it. Not malicious — just typos. A digit swapped during copy-paste, a trailing space, an O where a 0 should be, a number carried over from an old registration that was surrendered two years ago.

You find out at the worst possible time: GSTR-1 filing day, when the portal rejects the upload and you are staring at 400 rows with no idea which ones are broken.

Here is the thing most people do not know: a GSTIN carries its own checksum. You do not need an API, a subscription, or an internet connection to catch the majority of bad ones. The 15th character is a mathematical function of the first 14. If it does not match, the number is wrong — full stop.

This post builds a validator that checks that checksum and decodes the rest of the number, in 62 lines of Python. No dependencies outside the standard library.

What a GSTIN actually encodes

A GSTIN is 15 characters, and every slice means something:

2 7 A A P F U 0 9 3 9 F 1 Z V
└─┘ └───────────────────┘ │ │ │
 │           │            │ │ └─ checksum
 │           │            │ └─── always Z (reserved)
 │           │            └───── registration count for this PAN in this state
 │           └────────────────── the entity's 10-character PAN
 └────────────────────────────── state code (27 = Maharashtra)
Enter fullscreen mode Exit fullscreen mode

Two useful consequences fall out of this:

  1. The PAN is sitting right there in characters 3–12. You can cross-check it against your TDS records without asking the vendor for anything.
  2. The 4th character of that PAN tells you the entity type — C for company, P for proprietor, F for partnership or LLP, H for HUF, T for trust. Handy when you are deciding whether a vendor should have been deducted TDS at all.

The checksum algorithm

This is the part worth understanding, because it is the bit that catches typos.

Map every character to a value: 0-9 → 0–9, A-Z → 10–35. That is a base-36 alphabet. Then, for each of the first 14 characters:

  • Multiply its value by 1 if it is in an odd position, 2 if it is in an even position.
  • Add the quotient and remainder of that product divided by 36.

Sum all 14 contributions. The checksum character is (36 − sum mod 36) mod 36, mapped back through the same alphabet.

If that sounds like the Luhn algorithm on your credit card, it is the same family of idea — a weighted modular sum that catches single-character errors and most transpositions.

Nine lines of Python:

CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"

def checksum(first14):
    total = 0
    for i, ch in enumerate(first14):
        prod = CHARSET.index(ch) * (2 if i % 2 else 1)
        total += prod // 36 + prod % 36
    return CHARSET[(36 - total % 36) % 36]
Enter fullscreen mode Exit fullscreen mode

Sanity check it against a GSTIN you know is real before you trust it on 400 rows. 27AAPFU0939F1ZV should return V.

The full tool

Reads a CSV with a gstin column, writes the same CSV back with valid, error, state, pan, entity_type, and registration_no columns appended.

import csv
import re
import sys

CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
GSTIN_RE = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z][Z][0-9A-Z]$")
STATES = {
    "01": "Jammu & Kashmir", "02": "Himachal Pradesh", "03": "Punjab",
    "04": "Chandigarh", "05": "Uttarakhand", "06": "Haryana", "07": "Delhi",
    "08": "Rajasthan", "09": "Uttar Pradesh", "10": "Bihar", "11": "Sikkim",
    "12": "Arunachal Pradesh", "13": "Nagaland", "14": "Manipur",
    "15": "Mizoram", "16": "Tripura", "17": "Meghalaya", "18": "Assam",
    "19": "West Bengal", "20": "Jharkhand", "21": "Odisha",
    "22": "Chhattisgarh", "23": "Madhya Pradesh", "24": "Gujarat",
    "26": "Dadra & Nagar Haveli and Daman & Diu", "27": "Maharashtra",
    "29": "Karnataka", "30": "Goa", "31": "Lakshadweep", "32": "Kerala",
    "33": "Tamil Nadu", "34": "Puducherry", "35": "Andaman & Nicobar",
    "36": "Telangana", "37": "Andhra Pradesh", "38": "Ladakh",
}
PAN_ENTITY = {
    "C": "Company", "P": "Individual / Proprietor", "H": "HUF",
    "F": "Partnership Firm / LLP", "A": "AOP", "T": "Trust",
    "B": "Body of Individuals", "L": "Local Authority",
    "J": "Artificial Juridical Person", "G": "Government",
}

def checksum(first14):
    total = 0
    for i, ch in enumerate(first14):
        prod = CHARSET.index(ch) * (2 if i % 2 else 1)
        total += prod // 36 + prod % 36
    return CHARSET[(36 - total % 36) % 36]

def validate(raw):
    g = (raw or "").strip().upper().replace(" ", "")
    if len(g) != 15:
        return {"gstin": g, "valid": False, "error": f"length {len(g)}, expected 15"}
    if not GSTIN_RE.match(g):
        return {"gstin": g, "valid": False, "error": "structure does not match GSTIN pattern"}
    if g[:2] not in STATES:
        return {"gstin": g, "valid": False, "error": f"unknown state code {g[:2]}"}
    if checksum(g[:14]) != g[14]:
        return {"gstin": g, "valid": False,
                "error": f"checksum mismatch (expected {checksum(g[:14])})"}
    return {
        "gstin": g, "valid": True, "error": "",
        "state": STATES[g[:2]], "pan": g[2:12],
        "entity_type": PAN_ENTITY.get(g[5], "Unknown"),
        "registration_no": g[12],
    }

def main(inp, outp):
    rows = []
    with open(inp, newline="", encoding="utf-8") as f:
        for r in csv.DictReader(f):
            rows.append({**r, **validate(r.get("gstin", ""))})
    fields = list(dict.fromkeys(k for r in rows for k in r))
    with open(outp, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=fields)
        w.writeheader()
        w.writerows(rows)
    bad = sum(1 for r in rows if not r["valid"])
    print(f"{len(rows)} checked | {bad} invalid | wrote {outp}")

if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])
Enter fullscreen mode Exit fullscreen mode

Running it

vendors.csv:

vendor,gstin
Acme Traders,27AAPFU0939F1ZV
Bharat Supplies,29AAGCB7383J1Z4
Typo Enterprises,27AAPFU0939F1ZX
Short Code Ltd,27AAPFU0939F1Z
Bad State Co,99AAPFU0939F1ZV
Enter fullscreen mode Exit fullscreen mode
python gstin_validator.py vendors.csv checked.csv
Enter fullscreen mode Exit fullscreen mode
5 checked | 3 invalid | wrote checked.csv
Enter fullscreen mode Exit fullscreen mode

And checked.csv:

vendor,gstin,valid,error,state,pan,entity_type,registration_no
Acme Traders,27AAPFU0939F1ZV,True,,Maharashtra,AAPFU0939F,Partnership Firm / LLP,1
Bharat Supplies,29AAGCB7383J1Z4,True,,Karnataka,AAGCB7383J,Company,1
Typo Enterprises,27AAPFU0939F1ZX,False,checksum mismatch (expected V),,,,
Short Code Ltd,27AAPFU0939F1Z,False,"length 14, expected 15",,,,
Bad State Co,99AAPFU0939F1ZV,False,unknown state code 99,,,,
Enter fullscreen mode Exit fullscreen mode

Note the third row. ...1ZX instead of ...1ZV — one character off, structurally perfect, and a regex-only validator would have waved it straight through. The checksum caught it.

What this does not do

Be honest with yourself about the limits, because this is where people get burned:

  • It cannot tell you if a GSTIN is registered or active. A number can be arithmetically perfect and belong to nobody, or belong to a registration that was cancelled last March. Only the GST portal knows that.
  • It cannot tell you the number belongs to the vendor who gave it to you. Cross-checking the embedded PAN against your own records helps here, and costs you nothing.
  • The state list needs occasional maintenance. Codes 25 and 26 shifted when Dadra & Nagar Haveli merged with Daman & Diu; 38 (Ladakh) is recent.

So treat this as a filter, not a verdict. Run it across the whole master, fix the arithmetically-broken rows first — those are free wins, no external calls needed — and then spend your portal lookups on the ones that survive. On a 400-row vendor master that usually means you are checking 15 numbers online instead of 400.

Where to put it

The two places it earns its keep:

  1. At the point of entry. Wire validate() into whatever form or sheet captures a new vendor. Rejecting a bad GSTIN at creation time is worth ten times catching it at filing time.
  2. As a pre-flight check. Run it over the return data before you generate the upload file. Thirty seconds now, versus a rejected JSON and a re-run later.

The whole thing is standard library, so it drops into a Lambda, a cron job, or a Google Sheets Apps Script port without a requirements.txt.


Follow me on Twitter @automate_archit for daily AI automation tips.

Top comments (0)