How-to 10 min read

Validate a GSTIN in Python Without an API Call

Offline GSTIN validation in Python — the checksum, the fields the number encodes, and Django, DRF and Pydantic validators you can paste in.

Written and reviewed by the GSTIN API team Last reviewed

Most GSTIN mistakes are typos, and a typo can be caught without asking anybody. The last character of a GST number is a check digit computed from the fourteen in front of it, so a wrong number is arithmetically detectable in microseconds — no network, no API key, no credit spent. This article gives you that check as twenty lines of pure Python, then the maintained library if you would rather not own it, then paste-in validators for Django, Django REST Framework, Pydantic and pandas.

Write the checksum yourself

Start here even if you intend to install something. The algorithm is short, it is public, and understanding it is what lets you trust whatever you end up depending on. Each character takes its index in 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ, weights alternate 1 and 2 from the first character, each product is folded over 36, and the check digit is the character at (36 - total % 36) % 36.

# gstin.py — no imports beyond the standard library, no dependencies.
import re

CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"

# Position 13 is [1-9A-Z], never 0 — a zeroth registration is never issued.
# Position 14 is a literal Z. Position 15 may be any of the 36 characters.
PATTERN = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")


def normalise(value: str) -> str:
    # Strip first, validate second — rejecting "27 aaacr 5055k1z7" is
    # rejecting a correct registration over formatting.
    return "".join(ch for ch in str(value or "").upper() if ch.isalnum())


def check_digit(first14: str) -> str:
    total = 0

    for index, character in enumerate(first14):
        product = CHARSET.index(character) * (1 if index % 2 == 0 else 2)
        # Base-36 equivalent of Luhn's "add the digits" when a product overflows.
        total += product // 36 + product % 36

    # The outer % 36 is not decoration: a total that is already a multiple of
    # 36 would otherwise index 36, one past the end of CHARSET.
    return CHARSET[(36 - total % 36) % 36]


def is_valid_gstin(value: str) -> bool:
    gstin = normalise(value)

    # Shape first — check_digit() assumes every character is in CHARSET.
    if not PATTERN.match(gstin):
        return False

    return check_digit(gstin[:14]) == gstin[14]

That is the entire offline check. Test it against numbers chosen to break the implementations that look right:

assert is_valid_gstin("29AAACI1195H1ZI")      # Infosys — check digit is a LETTER
assert is_valid_gstin("27aaacr5055k1z7")      # lowercase must normalise, not fail
assert is_valid_gstin("27-AAACR-5055K1Z7")    # so must pasted punctuation
assert is_valid_gstin("00AAACR5055K1ZN")      # correct checksum, impossible state code

assert not is_valid_gstin("27AAACR5055K1Z0")  # check digit off by one
assert not is_valid_gstin("27AAACR5055K1Y7")  # position 14 is not Z
assert not is_valid_gstin("27AAACR5055K0Z7")  # entity code 0 is never issued

Or install it

The twenty lines above are correct and they will not stay interesting. The maintained version handles the edge cases, knows the state table, and is typed:

pip install gstin-toolkit

Zero dependencies, Python 3.9+, MIT-licensed, and it ships a py.typed marker so mypy and Pyright pick up the annotations rather than treating it as untyped. The import name is gstin_api:

from gstin_api import (gstin_check_digit, gstin_rejection_reason,
                       is_valid_gstin, matches_gstin_format, normalize_gstin)

is_valid_gstin("27AAACR5055K1Z7")           # True
matches_gstin_format("27AAACR5055K1Z0")     # True — shape is fine, checksum is not
gstin_check_digit("29AAACI1195H1Z")         # 'I' — letters happen, handle them
normalize_gstin(" 27-aaacr-5055k1z7 ")      # '27AAACR5055K1Z7'

# None when the number is valid; otherwise a short, showable explanation.
gstin_rejection_reason("27AAACR5055K1Z7")   # None — it is valid
gstin_rejection_reason("27AAACR5055K1Y7")   # 'Character 14 must be the literal Z'
gstin_rejection_reason("27AAACR5055K1Z0")   # 'Check digit mismatch — expected 7, found 0'
gstin_rejection_reason("27AAACR5055K1Z")    # 'Wrong length — a GSTIN is 15 characters, this is 14'
gstin_rejection_reason("")                  # 'Empty'

Read the fields the number encodes

A GSTIN is not an opaque identifier. Two digits of state code, ten characters of PAN, an entity code, a reserved Z, and the check digit — so you can populate half a vendor form from the number alone before you ever call an API.

from gstin_api import parse_gstin

parsed = parse_gstin("29AAACI1195H1ZI")

# ParsedGstin is a frozen dataclass and is truthy iff the GSTIN is valid,
# so `if parsed:` reads as "if this is a real, well-formed number".
if parsed:
    parsed.state_code, parsed.state       # '29', 'Karnataka'
    parsed.state_known                    # True — is 29 a code that exists?
    parsed.pan, parsed.pan_holder_type    # 'AAACI1195H', what its 4th char means
    parsed.entity_code                    # '1'
    parsed.registration_number_in_state   # which registration this is for the PAN
    parsed.check_digit                    # 'I'  (expected_check_digit agrees)
    parsed.parts                          # the labelled breakdown, as data
else:
    print(parsed.reason)                  # why it failed; None when valid

parsed.as_dict()   # plain dict, ready for JSON or a DataFrame row

state_known is the field worth wiring into your own checks. It separates the checksum is correct from the state code is one that exists, which is exactly the distinction 00AAACR5055K1ZN exploits. Collapsing those two facts into one boolean is how a validator ends up disagreeing with the API sitting behind it.

from gstin_api import explain_gstin, state_for_code

# A labelled breakdown — ideal for a tooltip or a debug view. Returns []
# unless the input is exactly 15 characters.
for part in explain_gstin("27AAACR5055K1Z7"):
    print(f"{part.label}: {part.value} — {part.meaning}")

state = state_for_code("27")            # also all_states(), search_states("delhi")
state.name, state.is_union_territory    # 'Maharashtra', False
state.intra_state_tax                   # which tax applies on a supply within it

Field-by-field meaning of every position, including what the fourth PAN character says about the entity type, is in GSTIN format explained.

A Django model and form validator

Django's validation order matters here. full_clean() runs clean_fields() — which is where field-level validators fire — before clean(). So the validator must tolerate un-normalised input, and normalisation has to happen somewhere that is guaranteed to run.

# models.py
from django.core.exceptions import ValidationError
from django.db import models
from gstin_api import gstin_rejection_reason, normalize_gstin


def validate_gstin(value: str) -> None:
    # gstin_rejection_reason normalises internally, so this is safe on a field
    # whose value has not been uppercased yet. Passing the reason through means
    # the user reads "the check digit does not agree", not "Enter a valid value".
    reason = gstin_rejection_reason(value)

    if reason is not None:
        raise ValidationError(reason, code="invalid_gstin")


class Vendor(models.Model):
    # 15 characters once normalised. The unique constraint is the whole reason
    # normalisation cannot be optional.
    gstin = models.CharField(max_length=15, unique=True, db_index=True,
                             validators=[validate_gstin])
    legal_name = models.CharField(max_length=255, blank=True)

    def clean(self):
        super().clean()
        self.gstin = normalize_gstin(self.gstin)

    def save(self, *args, **kwargs):
        # clean() only runs via full_clean(), which ModelForms call and
        # Model.objects.create() does not. This is the last line of defence
        # against a lowercase duplicate landing in the table.
        self.gstin = normalize_gstin(self.gstin)
        super().save(*args, **kwargs)
# forms.py
from django import forms
from gstin_api import gstin_rejection_reason, normalize_gstin

from .models import Vendor


class VendorForm(forms.ModelForm):
    # Deliberately no max_length — punctuation is stripped in clean_gstin.
    gstin = forms.CharField(label="GSTIN", strip=True)

    class Meta:
        model = Vendor
        fields = ["gstin", "legal_name"]

    def clean_gstin(self):
        gstin = normalize_gstin(self.cleaned_data["gstin"])
        reason = gstin_rejection_reason(gstin)

        if reason is not None:
            raise forms.ValidationError(reason)

        # Returning the normalised value puts the canonical form into
        # cleaned_data, and therefore into the model and the unique check.
        return gstin

The wider question of what a GSTIN field should do while someone is typing into it — when to validate, what to show, and when not to block submission — is covered in GST number validation in forms.

DRF and Pydantic

The same idea in both: a reusable field type that normalises on the way in and raises with a reason, so no call site has to remember to do either.

# serializers.py
from gstin_api import gstin_rejection_reason, normalize_gstin
from rest_framework import serializers


class GstinField(serializers.CharField):
    def __init__(self, **kwargs):
        # 20, not 15 — the raw value may still contain hyphens and spaces.
        kwargs.setdefault("max_length", 20)
        kwargs.setdefault("trim_whitespace", True)
        super().__init__(**kwargs)

    def to_internal_value(self, data):
        gstin = normalize_gstin(super().to_internal_value(data))
        reason = gstin_rejection_reason(gstin)

        if reason is not None:
            raise serializers.ValidationError(reason)

        # Returning the normalised value means the model, the cache key and
        # the API call all see one canonical spelling.
        return gstin


class VendorSerializer(serializers.ModelSerializer):
    gstin = GstinField()   # every endpoint that takes a GSTIN reuses this

    class Meta:
        model = Vendor
        fields = ["id", "gstin", "legal_name"]
# schemas.py — Pydantic v2
from typing import Annotated

from gstin_api import gstin_rejection_reason, normalize_gstin
from pydantic import AfterValidator, BaseModel


def _validated_gstin(value: str) -> str:
    gstin = normalize_gstin(value)
    reason = gstin_rejection_reason(gstin)

    if reason is not None:
        # Pydantic wraps ValueError into a ValidationError carrying the field
        # location, so FastAPI returns a 422 naming the offending field.
        raise ValueError(reason)

    return gstin


# AfterValidator runs once Pydantic has coerced the input to str, and its return
# value replaces the field — so the model always holds the normalised form.
Gstin = Annotated[str, AfterValidator(_validated_gstin)]


class Invoice(BaseModel):
    supplier_gstin: Gstin
    amount: float


Invoice(supplier_gstin=" 27-aaacr-5055k1z7 ", amount=1180.0)
# Invoice(supplier_gstin='27AAACR5055K1Z7', amount=1180.0)

A whole CSV column with pandas

Nothing here touches the network, so the only cost is Python function-call overhead. Validating a vendor master before you spend a single credit on it is the highest-leverage thing in this article.

import pandas as pd
from gstin_api import gstin_rejection_reason, normalize_gstin, parse_gstin

vendors = pd.read_csv("vendors.csv", dtype={"gstin": "string"})

# Normalise the column in place. Downstream joins and dedupes depend on this.
vendors["gstin"] = vendors["gstin"].fillna("").map(normalize_gstin)

# One pass gives both the verdict and the explanation for the rejects.
vendors["reason"] = vendors["gstin"].map(gstin_rejection_reason)
vendors["valid"] = vendors["reason"].isna()

# A reason column is what makes this file actionable by whoever owns it.
vendors.loc[~vendors["valid"], ["vendor_id", "gstin", "reason"]] \
       .to_csv("vendors-rejected.csv", index=False)

# Enrich the survivors from the number itself — no API call involved.
ok = vendors["valid"]
parsed = vendors.loc[ok, "gstin"].map(parse_gstin)
vendors.loc[ok, "state"] = parsed.map(lambda p: p.state)
vendors.loc[ok, "pan"] = parsed.map(lambda p: p.pan)

# Dedupe before anything billable. Vendor masters are full of repeats.
to_verify = vendors.loc[ok, "gstin"].drop_duplicates().tolist()

There is also a CLI, which reads stdin and speaks JSON, so it drops straight into a shell pipeline:

gstin-api validate 27AAACR5055K1Z7
gstin-api parse 29AAACI1195H1ZI --json
gstin-api explain 33AAACT2803M1ZI      # also: gstin-api states
cut -d, -f3 vendors.csv | gstin-api validate --json

When offline is not enough

Everything above proves one thing: the string is structurally possible. It says nothing about whether the registration exists, whose name is on it, or whether it was cancelled last quarter — because none of those facts are encoded in the number. That distinction is the whole subject of validation versus verification, and it is how checksum-valid GSTINs end up on fake invoices.

QuestionOffline validationRegister lookup
Is this a typo?Yes — catches every single-character slipAlso yes, at the cost of a request
Was this number ever issued?NoYes
Is the registration still active?NoYes
Whose name is on it?No — only the PAN and state are encodedYes, legal and trade name
Every row here is invisible to any offline validator, in any language.

The same package carries the client, and it validates before it spends anything:

from gstin_api import GstinApiClient, InvalidGstinError, RateLimitError

client = GstinApiClient()   # reads GSTINAPI_API_KEY from the environment

try:
    taxpayer = client.lookup("27AAACR5055K1Z7")
except InvalidGstinError:
    # Raised BEFORE any HTTP request is made. A typo can never cost a credit.
    taxpayer = None
except RateLimitError as error:
    print(error.message, error.status, error.is_fatal)
    raise

if taxpayer is None:
    print("Well-formed, but no registration exists with that number.")
else:
    taxpayer.legal_name, taxpayer.trade_name
    taxpayer.status, taxpayer.is_active           # 'Active', True
    taxpayer.taxpayer_type, taxpayer.constitution
    taxpayer.registration_date                    # a datetime.date, not a string
    taxpayer.principal_address, taxpayer.state
    taxpayer.filing_counts
    taxpayer.raw                                  # untouched payload, when you need it

For the list you built with pandas, verify_many dedupes, runs a bounded pool, and stops early rather than burning through your balance against a bad key:

result = client.verify_many(to_verify, workers=8)

result.results        # a VerificationResult per input GSTIN
result.looked_up      # how many actually reached the API and cost a credit
result.skipped        # rejected offline, or duplicates already resolved
result.stopped_early  # True if a fatal error aborted the run

That is the complete picture: a checksum you can own outright, a typed library if you would rather not, a validator for whichever framework you are in, and a client for the question arithmetic cannot answer. The integration patterns around the client — timeouts, caching, retries — are in the Python GST verification API guide, the endpoint reference is in the API documentation, and you can get a key when you need one. The package is on PyPI, and its full export list is on the Python package reference.

For a single number and no code at all, the GSTIN validator runs the same checksum entirely in your browser, and GST number search does the register lookup.

Frequently asked questions

Normalise the input to uppercase alphanumerics, match it against the pattern ^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$, then recompute the fifteenth character with the modulus-36 checksum and compare. That is about twenty lines of pure Python with no dependencies, and it runs in microseconds.
Yes — pip install gstin-toolkit gives you is_valid_gstin, gstin_rejection_reason and parse_gstin. It has zero dependencies, supports Python 3.9 and above, ships a py.typed marker so mypy and Pyright see the annotations, and is MIT-licensed.
Completely. The checksum and the format are arithmetic and a regular expression, so nothing leaves your process. What you cannot do offline is find out whether the number was ever issued or is still active — that requires a lookup against the government register.
Because 27aaacr5055k1z7, 27-AAACR-5055K1Z7 and 27 AAACR 5055K 1Z7 are the same registration but three different strings. Store one canonical uppercase form and your unique constraint, your cache keys and your joins all work. Normalise once, at the edge, before validation.
Something you can show the user. A boolean tells them a number is wrong; a reason tells them the check digit does not agree, or the fourteenth character is not Z, or the state code is not one the government issues. That difference is what stops a support ticket.
No. The algorithm is public, so a checksum-correct GSTIN is trivial to fabricate, and a cancelled registration keeps its correct checksum forever. Offline validation is a filter that makes the paid lookup cheap — it is not a substitute for it.

A note on accuracy. GST rules change often. This article reflects our understanding as of 8 September 2026 and is general information, not tax or legal advice. For the authoritative position, check gst.gov.in and cbic-gst.gov.in, or speak to a qualified tax professional about your specific situation.

Check a GSTIN right now, free

Five lookups a day, no account required. Status, filing history, e-invoicing and jurisdiction.

Free GST search tool

Build it into your product

One REST call, JSON back. Get an API key and 20 free verification credits in under a minute.

Start free