Test GST Numbers: Generate Valid GSTINs for Your Test Suite
Why hardcoded dummy GST numbers break your tests, how to generate checksum-correct GSTINs on demand, and the line you must not cross.
You are writing tests for something that takes a GST number — an onboarding form, an invoice importer, a vendor master — and you need values to feed it. The obvious move is to search for a list of dummy GSTINs. There isn't one — and what you actually want is better: a generator, a corpus of edge cases, and a clear sense of where the boundary sits between test data and a document you must never produce. All three are below.
There is no official list of test GSTINs
Say it plainly, because most people arrive here expecting otherwise. GSTN operates no public developer
portal, publishes no sandbox credentials, and has never reserved a range of GSTINs for testing the way
card networks reserve 4242 4242 4242 4242. There is no 00TEST0000T0Z0 waiting for
you. The reason is structural: GSTN's own APIs go only to licensed GST Suvidha Providers under a commercial
agreement, so there is no self-serve tier to hang a sandbox off — the whole story is in
is there a free government GST API.
Why a hardcoded dummy number rots
The tempting shortcut is to search for a well-known company, copy its GSTIN off a Google result or an invoice PDF, and paste it into a fixture file. It works on the day you do it.
Then it rots. That number belongs to a live registration whose status field is maintained by
the GST department, not by you. Registrations get cancelled. They get suspended for non-filing. Businesses
restructure and surrender a state registration. Your test asserting
result.status == 'Active' is, in effect, a monitoring check on a company you have no
relationship with — and it will go red in CI on a Tuesday morning during an unrelated deploy, and you will
spend forty minutes finding out why.
| Approach | Passes validation? | Failure mode |
|---|---|---|
Typed by hand, e.g. 27ABCDE1234F1Z5 | Usually not | The check digit is almost certainly wrong, so the fixture silently exercises only your rejection path |
| A real company's GSTIN | Yes | Its live status can change at any time; you have coupled your suite to a third party's compliance |
| Generated from a PAN and state code | Yes, by construction | None, provided it never leaves the test environment |
The first row is worth dwelling on, because it is the quiet one. A made-up number has roughly a
1 in 36 chance of accidentally having a correct check digit. So a hand-typed fixture is
about 97% likely to be rejected by your own validator — which means a test called
test_accepts_valid_gstin can sit green for a year while never once exercising the accept path.
We measured that probability in the GSTIN checksum algorithm.
Generate one from a PAN and a state code
A GSTIN is not random. It is assembled, and the last character is arithmetic:
^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$
27 AAACR5055K 1 Z 7
│ │ │ │ └─ check digit, computed from the 14 before it
│ │ │ └──── reserved, always a literal 'Z'
│ │ └─────── entity code: '1'-'9' then 'A'-'Z', never '0'
│ └─────────────────── the 10-character PAN
└─────────────────────── 2-digit state code
So to build one you choose the first fourteen characters and compute the fifteenth. The algorithm is a
modulus-36 Luhn variant: values are indices into
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ, weights alternate 1 and 2 from index 0, each product is
folded as floor(p / 36) + p % 36, and the check digit is the character at index
(36 - sum % 36) % 36. The derivation and a worked example are in
the checksum article; the pattern itself is picked apart in
the GSTIN regex explained. You do not have to implement any of
it — our libraries are MIT-licensed, dependency-free, and expose the constructor directly:
# pip install gstin-toolkit
from gstin_api import build_gstin, is_valid_gstin, parse_gstin
build_gstin("AAACR5055K", "27") # '27AAACR5055K1Z7'
build_gstin("AAACR5055K", "29", entity_code="2") # second registration in Karnataka
build_gstin("AAACI1195H", "29") # '29AAACI1195H1ZI' - letter check digit
is_valid_gstin("27AAACR5055K1Z7") # True
parse_gstin("27AAACR5055K1Z7").state_code # '27'
build_gstin("NOTAPAN", "27") # raises ValueError
build_gstin("AAACR5055K", "27", entity_code="0") # raises ValueError
// npm install @dlminds/gstin-api
import { buildGstin, isValidGstin, gstinCheckDigit } from '@dlminds/gstin-api';
buildGstin({ pan: 'AAACR5055K', stateCode: '27' }); // '27AAACR5055K1Z7'
buildGstin({ pan: 'AAACR5055K', stateCode: '29', entityCode: '2' });
isValidGstin('27AAACR5055K1Z7'); // true
gstinCheckDigit('27AAACR5055K1Z'); // '7'
buildGstin({ pan: 'nope', stateCode: '27' }); // throws TypeError
Both throw on malformed input rather than returning something plausible-but-wrong, which matters in a factory: a typo in a fixture PAN should blow up the test run loudly, not quietly produce a number that exercises the wrong branch. Full API surfaces on PyPI and npm, with walkthroughs in Python and Node.js and TypeScript.
Wiring it into factories and fixtures
The useful property in a factory is determinism: the same seed produces the same GSTIN, so a failing test reproduces locally and a snapshot does not churn. Build the PAN from a seeded random source rather than a global one.
The fourth character of a PAN encodes entity type, and honouring it makes fixtures read like real data —
C company, P individual or proprietor, F firm or LLP,
H HUF, T trust, A AOP, B BOI, G
government, L local authority, J artificial juridical person.
# conftest.py
import random
import string
import pytest
from gstin_api import build_gstin
LETTERS = string.ascii_uppercase
def make_pan(rng: random.Random, entity_type: str = "C") -> str:
"""3 letters, entity type, 1 letter, 4 digits, 1 letter -> e.g. AAACR5055K."""
pick = lambda pool, n: "".join(rng.choice(pool) for _ in range(n))
return pick(LETTERS, 3) + entity_type + pick(LETTERS, 1) + pick(string.digits, 4) + pick(LETTERS, 1)
@pytest.fixture
def gstin_factory():
def factory(seed=0, state_code="27", entity_type="C", entity_code="1"):
rng = random.Random(seed) # same seed, same GSTIN, every run
return build_gstin(make_pan(rng, entity_type), state_code, entity_code)
return factory
def test_vendor_accepts_a_generated_gstin(client, gstin_factory):
response = client.post("/vendors", json={"gstin": gstin_factory(seed=1)})
assert response.status_code == 201
The same shape in TypeScript, for a Vitest or Jest suite:
// tests/factories/gstin.ts
import { buildGstin } from '@dlminds/gstin-api';
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
// A tiny deterministic PRNG - no dependency, reproducible across machines.
function seeded(seed: number) {
let state = seed + 0x6d2b79f5;
return () => {
state = Math.imul(state ^ (state >>> 15), state | 1);
state ^= state + Math.imul(state ^ (state >>> 7), state | 61);
return ((state ^ (state >>> 14)) >>> 0) / 4294967296;
};
}
export function makeGstin({ seed = 0, stateCode = '27', entityType = 'C' } = {}) {
const rand = seeded(seed);
const pick = (set: string) => set[Math.floor(rand() * set.length)];
const take = (set: string, n: number) => Array.from({ length: n }, () => pick(set)).join('');
// 3 letters, entity type, 1 letter, 4 digits, 1 letter.
const pan = take(LETTERS, 3) + entityType + pick(LETTERS) + take('0123456789', 4) + pick(LETTERS);
return buildGstin({ pan, stateCode, entityCode: '1' });
}
makeGstin({ seed: 7 }); // stable across runs
makeGstin({ seed: 7, stateCode: '29' }); // same PAN, different state
The inputs that actually break validators
One valid GSTIN tells you very little. What catches real bugs is a corpus chosen so that each row breaks a different plausible-but-wrong implementation. This is ours, and you are welcome to copy it wholesale.
| Input | Expected | What it catches |
|---|---|---|
27AAACR5055K1Z7 | Valid | The happy path — a real, live registration |
29AAACI1195H1ZI | Valid | Check digit is a letter. Breaks any pattern ending [0-9]$ |
99AAACR5055KAZN | Valid | Alphabetic entity code A, and state code 99 |
00AAACR5055K1ZN | Valid checksum | Correct check digit, state code 00 is never issued. Decide deliberately what you report here |
27aaacr5055k1z7 | Valid | Lowercase must normalise, not fail |
27-AAACR 5055 K1Z7 | Valid | Whitespace and punctuation people paste out of PDFs |
27AAACR5055K1Z0 | Invalid | Pattern passes, checksum fails — the case a regex-only validator waves through |
27AAACR5055K1Y7 | Invalid | Position 14 is not Z |
27AAACR5055K0Z7 | Invalid | Entity code 0, which is never issued |
27AAACR50X5K1Z7 | Invalid | Letter inside the PAN's digit block |
271234R5055K1Z7 | Invalid | Digits inside the PAN's letter block |
27AAACR5055K1Z | Invalid | 14 characters — off-by-one on the length guard |
27AAACR5055K1Z79 | Invalid | 16 characters — a validator that only slices the first 15 |
'' and ' ' | Invalid | Empty and whitespace-only; must not raise |
'not a gstin' | Invalid | Free text; must not raise either |
00AAACR5055K1ZN deserves the extra thought. Its check digit is genuinely correct, so
"structurally valid" and "could plausibly have been issued" are two different facts about it. Collapse them
into one boolean and your validator will eventually disagree with the API behind it. The distinction is the
subject of validation versus verification.
Testing the API call without calling it
Generation solves your validation tests. Your verification tests need a different tool, because the correct number of live API calls in a unit suite is zero — live calls cost credits, need a key in CI, and make the suite flaky in a way that has nothing to do with your code. So stub it.
The endpoint is GET https://gstinapi.com/api/get-taxpayer-info/{gstin} with an
X-API-Key header. A found record carries
taxpayer_data.status_code == 1 alongside name, tradename,
status, type, constitution, registrationDate and
pradr, the principal address. Full reference in the API documentation.
# test_verification.py
from unittest.mock import patch
from app.gst import verify_gstin
FOUND = {"taxpayer_data": {
"status_code": 1, "name": "ACME PRIVATE LIMITED", "tradename": "Acme",
"status": "Active", "constitution": "Private Limited Company",
"registrationDate": "01/07/2017",
}}
NOT_FOUND = {"taxpayer_data": {"status_code": 0}}
@patch("app.gst.session.get")
def test_maps_an_active_registration(mock_get):
mock_get.return_value.status_code, mock_get.return_value.json.return_value = 200, FOUND
result = verify_gstin("27AAACR5055K1Z7")
assert result.legal_name == "ACME PRIVATE LIMITED"
assert result.is_active is True
@patch("app.gst.session.get")
def test_generated_gstin_is_not_found_rather_than_an_error(mock_get, gstin_factory):
mock_get.return_value.status_code, mock_get.return_value.json.return_value = 200, NOT_FOUND
assert verify_gstin(gstin_factory(seed=3)).outcome == "not_found"
@patch("app.gst.session.get")
def test_never_calls_out_for_a_checksum_invalid_gstin(mock_get):
assert verify_gstin("27AAACR5055K1Z0").outcome == "invalid_format"
mock_get.assert_not_called()
The same in Vitest, stubbing fetch. Better still, design the client to take a transport — a
transport= callable in Python, a fetch option in Node — so tests inject a fake
rather than monkey-patching a global:
import { expect, it, vi } from 'vitest';
import { verifyGstin } from '../src/gstinClient';
import { makeGstin } from './factories/gstin';
const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
it('treats a generated GSTIN as not_found, not as a failure', async () => {
const fetch = vi.fn().mockResolvedValue(json({ taxpayer_data: { status_code: 0 } }));
expect((await verifyGstin(makeGstin({ seed: 11 }), { fetch })).outcome).toBe('not_found');
});
it.each([401, 402, 429, 503])('surfaces HTTP %i as an upstream error', async (status) => {
const fetch = vi.fn().mockResolvedValue({ ok: false, status, json: async () => ({}) });
expect((await verifyGstin('27AAACR5055K1Z7', { fetch, retries: 0 })).outcome)
.toBe('upstream_error');
});
Keep exactly one test that touches the network, tag it, and run it on a schedule rather than on every commit. That contract test tells you when the response shape changes; the stubbed suite tells you whether your code is right. They are different jobs. The wider integration pattern is in integrating the API in Node.js.
The line you must not cross
Everything above produces strings that pass validation. It is worth being precise about what that does and does not mean, once, without hedging.
The practical safeguards are dull and effective: prefix generated PANs so they are greppable, never seed them into an environment that sends email or files returns, and fail your build if a fixture GSTIN turns up in production data. If you have received an invoice carrying a number you suspect was fabricated, check it with the GST number search and read how to check if a GST number is fake or how to report a fake GST invoice.
So: no official list, and no need for one. A generator gives you unlimited valid numbers, the corpus above gives you the invalid ones that matter, and stubs keep the network out of your unit tests. For a single number right now, the free GSTIN validator runs entirely in your browser, and the format itself is laid out in GSTIN format explained.
Frequently asked questions
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 toolBuild it into your product
One REST call, JSON back. Get an API key and 20 free verification credits in under a minute.
Start freeMore on developer integration guides
Browse every guide in the GST API blog.