DEV Community

Archit Mittal
Archit Mittal

Posted on • Originally published at architmittal.com

Build a GST Invoice Data Extractor in 45 Lines of Python

Every accountant I know has the same Monday ritual: open 60 supplier invoices, squint at each PDF, and retype the GSTIN, invoice number, taxable value and tax split into a spreadsheet. It takes three hours. It produces typos. And it happens again next Monday.

Here is a 45-line Python script that does it in about four seconds.

What we are building

Point the script at a folder of invoice PDFs. It reads each one, pulls out:

  • Supplier GSTIN
  • Invoice number and date
  • Taxable value
  • CGST / SGST / IGST breakup
  • Grand total

...and writes everything to a single CSV you can hand straight to your CA or import into Tally.

Setup

pip install pdfplumber
Enter fullscreen mode Exit fullscreen mode

That is the only dependency. pdfplumber gives us reliable text extraction with layout awareness, which matters because invoice PDFs love to put the amount three columns away from its label.

The code

import re, csv, sys
from pathlib import Path
import pdfplumber

# GSTIN: 2-digit state code, 5 letters PAN, 4 digits, 1 letter, 1 alnum, 'Z', 1 checksum
GSTIN   = re.compile(r"\b\d{2}[A-Z]{5}\d{4}[A-Z][A-Z\d]Z[A-Z\d]\b")
INV_NO  = re.compile(r"invoice\s*(?:no\.?|number|#)\s*[:\-]?\s*([A-Za-z0-9/\-]{3,25})", re.I)
DATE    = re.compile(r"\b(\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4})\b")
TAXABLE = re.compile(r"taxable\s*(?:value|amount)[^\d]{0,25}([\d,]+\.?\d{0,2})", re.I)
TAX     = re.compile(r"\b(CGST|SGST|UTGST|IGST)\b\s*(?:@\s*)?(?:[\d.]+\s*%)?[^\d\n]{0,30}([\d,]+\.\d{2})", re.I)
TOTAL   = re.compile(r"(?:grand\s*total|total\s*(?:invoice\s*)?(?:value|amount))[^\d]{0,25}([\d,]+\.?\d{0,2})", re.I)

def num(s):
    return float(s.replace(",", "")) if s else 0.0

def first(pattern, text, group=1):
    m = pattern.search(text)
    return m.group(group).strip() if m else ""

def parse(pdf_path):
    with pdfplumber.open(pdf_path) as pdf:
        text = "\n".join(page.extract_text() or "" for page in pdf.pages)

    taxes = {"CGST": 0.0, "SGST": 0.0, "IGST": 0.0}
    for head, amount in TAX.findall(text):
        head = head.upper().replace("UTGST", "SGST")
        taxes[head] += num(amount)

    return {
        "file": pdf_path.name,
        "gstin": first(GSTIN, text, 0),
        "invoice_no": first(INV_NO, text),
        "date": first(DATE, text),
        "taxable_value": num(first(TAXABLE, text)),
        **{k.lower(): v for k, v in taxes.items()},
        "total": num(first(TOTAL, text)),
    }

def main(folder):
    rows = []
    for pdf in sorted(Path(folder).glob("*.pdf")):
        try:
            rows.append(parse(pdf))
            print(f"OK   {pdf.name}")
        except Exception as e:
            print(f"FAIL {pdf.name}: {e}")

    if not rows:
        return
    with open("invoices.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=rows[0].keys())
        w.writeheader()
        w.writerows(rows)
    print(f"\nWrote {len(rows)} invoices -> invoices.csv")
    print(f"Total tax credit: Rs {sum(r['cgst'] + r['sgst'] + r['igst'] for r in rows):,.2f}")

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else ".")
Enter fullscreen mode Exit fullscreen mode

Run it:

python gst_extract.py ~/Downloads/august-invoices
Enter fullscreen mode Exit fullscreen mode
OK   AWS-INV-2026-0812.pdf
OK   RELIANCE-JIO-8871.pdf
FAIL scan_003.pdf: no extractable text
OK   ZOHO-IN-44120.pdf

Wrote 3 invoices -> invoices.csv
Total tax credit: Rs 41,208.60
Enter fullscreen mode Exit fullscreen mode

Why each regex looks like that

The GSTIN pattern is not a guess. A GSTIN is exactly 15 characters with a fixed shape: two state-code digits, the ten-character PAN (five letters, four digits, one letter), an entity number, a literal Z, and a checksum character. Encoding that structure means you match real GSTINs and skip the random 15-character order IDs floating around the same page.

Note first(GSTIN, text, 0) uses group 0 — the whole match — because the pattern has no capture groups.

[^\d\n]{0,30} is the workhorse. Invoice layouts scatter the label and its number across a table row. This says "allow up to 30 non-digit characters between the word CGST and the amount, but never cross a line break." Without the \n exclusion you will happily grab a number from two rows down.

The optional (?:@\s*)?(?:[\d.]+\s*%)? is not optional in practice. My first version of this regex was just \b(CGST|...)\b[^\d\n]{0,30}(...) and it silently returned zero taxes on every invoice I threw at it. The reason: almost every real invoice writes the rate right after the label — CGST @ 9%, IGST 18% — and 9 is a digit, so [^\d\n] stopped dead before ever reaching the amount. Consuming the rate first fixes it. Test your regexes against actual invoices, not the one you imagined.

Taxes are summed, not assigned. Multi-rate invoices list CGST three times, once per HSN slab. findall plus += handles that. Single-rate invoices just add once and nothing breaks.

UTGST folds into SGST because for input-credit bookkeeping they behave identically, and keeping a fourth column that is empty 99% of the time is noise.

The failure you will hit first

FAIL scan_003.pdf: no extractable text means the PDF is a scan — a photo of paper with no text layer. pdfplumber cannot read pixels. Add OCR as a fallback:

pip install pytesseract pdf2image  # plus: brew install tesseract poppler
Enter fullscreen mode Exit fullscreen mode
def read_text(pdf_path):
    with pdfplumber.open(pdf_path) as pdf:
        text = "\n".join(p.extract_text() or "" for p in pdf.pages)
    if len(text.strip()) > 100:
        return text
    from pdf2image import convert_from_path
    import pytesseract
    return "\n".join(pytesseract.image_to_string(img)
                     for img in convert_from_path(pdf_path, dpi=300))
Enter fullscreen mode Exit fullscreen mode

Swap the first two lines of parse() for text = read_text(pdf_path). OCR is roughly 2 seconds per page instead of 40 milliseconds, so the length check matters — only scanned files pay that cost.

Make it actually trustworthy

Regex extraction on invoices is right maybe 90-95% of the time, and the 5% is exactly what will burn you at filing time. One cheap guard catches most of it — arithmetic that should balance:

def flag(row):
    expected = row["taxable_value"] + row["cgst"] + row["sgst"] + row["igst"]
    if row["total"] and abs(expected - row["total"]) > 1.0:
        return f"mismatch: {expected:.2f} vs {row['total']:.2f}"
    if row["igst"] and (row["cgst"] or row["sgst"]):
        return "both IGST and CGST/SGST present"
    return ""
Enter fullscreen mode Exit fullscreen mode

Add row["flag"] = flag(row) before appending, sort the CSV by that column, and you review eight invoices by hand instead of sixty. The second check is worth keeping: IGST and CGST/SGST are mutually exclusive on a normal invoice, so seeing both almost always means the regex grabbed a number from a comparison table or a terms-and-conditions block.

Where to take it next

  • Schedule it. A cron entry pointed at your invoice folder means the CSV is ready before you are.
  • Pull from email directly. imaplib plus a filter on has:attachment gets the PDFs into the folder without you touching anything.
  • Validate GSTINs offline. The 15th character is a mod-36 checksum. Fifteen lines of Python catches typo'd GSTINs before the portal rejects your return.
  • Split by supplier GSTIN into separate sheets, which maps neatly onto GSTR-2B reconciliation.

The whole thing is 45 lines because it does one job: text in, structured rows out. That constraint is a feature — every line is readable, and when a supplier changes their invoice template you will find the regex that needs adjusting in under a minute.

Three hours a month, gone.


Follow me on Twitter @automate_archit for daily AI automation tips

Top comments (0)