PassportOCR

Passport OCR in Python: PassportEye, an MRZ parser or an API

Published 2026-11-03 · Updated 2026-11-10 · By the Koncile team

To read a passport in Python, use PassportEye (open source, MRZ only, needs Tesseract), write a short MRZ parser that recomputes the check digits, or call an API that returns the printed fields and the MRZ as JSON. This guide gives working code for all three, tested on the ICAO specimen passport, and says what each one misses.

Hands holding an open passport over a laptop keyboard with code on screen.

Three ways to do passport OCR in Python

Python developers who need the data of a passport usually pick one of three routes. The first is open source: PassportEye finds the machine readable zone (MRZ) in an image and reads it with the Tesseract OCR engine. The second is to write an MRZ parser yourself, on top of any OCR output, so you control every check. The third is a hosted API that reads the whole data page and returns named fields as JSON. They do not return the same thing, and the difference matters more than the code.

RouteWhat you getWhat you installMain limit
PassportEye + TesseractThe MRZ only: type, country, number, names, dates, sex, check digit flagspip package, numpy, scipy, scikit-image, the tesseract binaryIts author reports about 80 % recognition when the MRZ is clearly visible; 10 s or more on some images
Your own MRZ parserExactly the fields and checks you write, from two or three lines of textNothing beyond PythonNeeds clean MRZ text from somewhere else; reads nothing outside the zone
Koncile API (requests)Fields of the template you define (printed zone and MRZ lines), a confidence score per valuerequests and an API keyPaid per page after 20 free credits; your files leave your servers

The limit applies to all three: none of them reads the chip, matches the face with a selfie or checks liveness. They read characters. Whether the person in front of you owns the passport is another problem, covered in a short section at the end.

Option 1: PassportEye, the open source MRZ reader

PassportEye is a Python package by Konstantin Tretyakov, under the MIT license (version 2.2.2 on PyPI, March 2025; about 470 stars on GitHub when we read the repository on 3 November 2026). It looks for anything that resembles an MRZ on the page, crops that region, sends it to Tesseract and parses the result. It handles the passport format (TD3), ID card formats (TD1, TD2) and visas. The format names are explained in our guide to MRZ formats.

$ pip install PassportEye          # also needs the tesseract binary on the PATH
$ mrz --json passport.jpg
{"mrz_type": "TD3", "valid_score": 100, "type": "P", "country": "UTO",
 "number": "L898902C3", "date_of_birth": "740812", "expiration_date": "120415",
 "nationality": "UTO", "sex": "F", "names": "ANNA MARIA", "surname": "ERIKSSON",
 "personal_number": "ZE184226B<<<<<", "valid_number": true, ...}

In code, the entry point is a single function. It returns None when no zone was found, otherwise an object whose to_dict() gives the fields, the raw check digits and one valid_ flag per check digit:

from passporteye import read_mrz

mrz = read_mrz("passport.jpg")          # a path or a byte stream
if mrz is None:
    print("No MRZ found: crop tighter, add light, or try --legacy")
else:
    data = mrz.to_dict()
    print(data["mrz_type"], data["valid_score"])   # e.g. TD3 100
    print(data["surname"], data["names"], data["number"])
    print(data["date_of_birth"], data["expiration_date"])  # raw YYMMDD strings

What PassportEye does well, and where it stops

  • It is free and local. No document leaves the machine, which settles a lot of privacy questions for a prototype.
  • valid_score is useful. It runs from 0 to 100 and counts check digits, line lengths and the first letter. A score of 100 means the text is internally consistent, not that the document is real.
  • Dates stay raw. date_of_birth and expiration_date come back as YYMMDD strings; the century is yours to decide.
  • Only the MRZ. The printed name with accents, the place of birth, the issuing authority and the issue date are outside the zone, so PassportEye never sees them.
  • Tesseract decides the quality. The README recommends the legacy Tesseract model (mrz --legacy) because it reads MRZ text better, and notes failures on blurred scans and on some ID cards whose MRZ sits close to the rest of the card.

Other open source options exist (FastMRZ, AGPLv3, which adds an ONNX model to locate the zone; the pyimagesearch tutorial from 2015, which only detects the zone with OpenCV). Check the license before shipping: an AGPL package inside a hosted product has obligations that MIT does not.

Option 2: write your own MRZ parser in Python

Parsing an MRZ is short work once you have the two lines of text, and writing it yourself is the only way to know exactly what is checked. The function below reads a passport MRZ (TD3, two lines of 44 characters), converts the dates, and recomputes the five check digits with the rule of ICAO Doc 9303 part 3: weights 7, 3, 1, letters A = 10 to Z = 35, the filler « < » = 0, keep the last digit of the sum. The full rule is explained in our guide to the MRZ checksum.

from datetime import date

WEIGHTS = (7, 3, 1)

def char_value(c: str) -> int:
    if c.isdigit():
        return int(c)
    if c == "<":
        return 0
    return ord(c) - 55  # A = 10 ... Z = 35

def check_digit(field: str) -> int:
    return sum(char_value(c) * WEIGHTS[i % 3] for i, c in enumerate(field)) % 10

def yymmdd(s: str, future: bool) -> date:
    yy, mm, dd = int(s[:2]), int(s[2:4]), int(s[4:6])
    pivot = date.today().year % 100
    if future:  # expiry dates are rarely more than 10 years ahead
        century = 2000 if yy <= pivot + 20 else 1900
    else:       # birth dates are in the past
        century = 1900 if yy > pivot else 2000
    return date(century + yy, mm, dd)

def parse_td3(line1: str, line2: str) -> dict:
    line1, line2 = line1.strip().upper(), line2.strip().upper()
    if len(line1) != 44 or len(line2) != 44:
        raise ValueError(f"TD3 lines must be 44 characters, got {len(line1)} and {len(line2)}")
    surname, _, given = line1[5:].partition("<<")
    number, dob, expiry, personal = line2[0:9], line2[13:19], line2[21:27], line2[28:42]
    composite = line2[0:10] + line2[13:20] + line2[21:43]
    checks = {
        "document_number": check_digit(number) == int(line2[9]),
        "date_of_birth": check_digit(dob) == int(line2[19]),
        "date_of_expiry": check_digit(expiry) == int(line2[27]),
        "personal_number": line2[42] in "<0" if set(personal) == {"<"} else check_digit(personal) == int(line2[42]),
        "composite": check_digit(composite) == int(line2[43]),
    }
    return {
        "document_type": line1[0:2].rstrip("<"),
        "issuing_state": line1[2:5],
        "surname": surname.replace("<", " ").strip(),
        "given_names": given.replace("<", " ").strip(),
        "document_number": number.rstrip("<"),
        "nationality": line2[10:13],
        "date_of_birth": yymmdd(dob, future=False).isoformat(),
        "sex": line2[20],
        "date_of_expiry": yymmdd(expiry, future=True).isoformat(),
        "personal_number": personal.rstrip("<"),
        "checks": checks,
        "valid": all(checks.values()),
    }

We ran it on the ICAO specimen passport (Anna Maria Eriksson, Utopia) and on the same line with one character misread:

>>> parse_td3("P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
...           "L898902C36UTO7408122F1204159ZE184226B<<<<<10")
{'document_type': 'P', 'issuing_state': 'UTO', 'surname': 'ERIKSSON',
 'given_names': 'ANNA MARIA', 'document_number': 'L898902C3', 'nationality': 'UTO',
 'date_of_birth': '1974-08-12', 'sex': 'F', 'date_of_expiry': '2012-04-15',
 'personal_number': 'ZE184226B', 'checks': {'document_number': True,
 'date_of_birth': True, 'date_of_expiry': True, 'personal_number': True,
 'composite': True}, 'valid': True}

# one character misread (the 8 of L898902C3 read as B):
{'document_number': False, 'date_of_birth': True, 'date_of_expiry': True,
 'personal_number': True, 'composite': False}

Two details make or break such a parser. Names: the surname ends at the first « << », and single « < » inside a name are spaces. Dates: a two digit year needs a rule, here a birth date is in the past and an expiry date is at most twenty years ahead. For ID cards in TD1 (three lines of 30) the positions change; the table of positions is in the MRZ formats guide, and the same check_digit function works unchanged.

FieldLine 2 positions (TD3)Check digit atNotes
Document number1 to 910Fillers « < » pad numbers shorter than 9
Nationality11 to 13noneThree letter code, D for Germany
Date of birth14 to 1920YYMMDD, unknown parts may be « < »
Sex21noneF, M, or « < » when unspecified
Date of expiry22 to 2728YYMMDD
Personal number29 to 4243Check digit may be « < » or 0 when empty
Composite1 to 10, 14 to 20, 22 to 4344Covers the whole line except nationality and sex

Option 3: passport OCR with the Koncile API in Python

A hosted API reads the whole data page, not only the zone, and returns values with a confidence score. The calls below are the ones documented on docs.koncile.ai as of 3 November 2026. The flow has three steps: you upload one file, you receive a task id, you poll that task until it is done.

Set up: an API key, a folder and a passport template

The key is generated in the web app under Settings > API, by a company administrator, and is shown only once: keep it in an environment variable, never in the code. Fields are defined in a template (a list of fields to extract), which lives in a folder; create a passport template in the app from the template library, or through the API endpoints create_folder, create_template and create_field. Note the two ids.

$ pip install requests
$ export KONCILE_API_KEY="..."        # Settings > API in app.koncile.ai, shown once
$ export KONCILE_FOLDER_ID=12         # ids of the folder and passport template
$ export KONCILE_TEMPLATE_ID=34       # you created in the app
$ curl -X POST https://api.koncile.ai/v1/check_api_key \
    -H "Authorization: Bearer $KONCILE_API_KEY"
{"success": true}

Upload the passport and poll the result

The script below is complete. Upload goes to /v1/upload_file/ (the trailing slash is required, without it the API answers 307), one file per request, JPEG or PNG up to 10 MB, PDF up to 300 MB and 150 pages. Results come from /v1/fetch_tasks_results/. Polling reads are limited to 2 requests per second and uploads to 1 per second per key, so the script waits 2 seconds between reads and honours the Retry-After header of a 429 answer.

import os
import sys
import time

import requests

API = os.environ.get("KONCILE_API_URL", "https://api.koncile.ai/v1")
HEADERS = {"Authorization": f"Bearer {os.environ['KONCILE_API_KEY']}"}
FOLDER_ID = int(os.environ["KONCILE_FOLDER_ID"])      # folder that holds your passport template
TEMPLATE_ID = int(os.environ["KONCILE_TEMPLATE_ID"])  # passport template (fields + MRZ lines)


def request(method, url, **kwargs):
    """One call, with a back-off on 429 as the API asks (Retry-After header)."""
    for _ in range(5):
        r = requests.request(method, url, headers=HEADERS, timeout=60, **kwargs)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "1")))
            continue
        if r.status_code >= 400:
            raise RuntimeError(f"{r.status_code}: {r.json().get('detail', r.text)}")
        return r.json()
    raise RuntimeError("still rate limited after 5 attempts")


def upload(path):
    with open(path, "rb") as f:  # one file per request: JPEG or PNG up to 10 MB, PDF up to 300 MB
        data = request("POST", f"{API}/upload_file/",
                       params={"folder_id": FOLDER_ID, "template_id": TEMPLATE_ID},
                       files={"files": f})
    return data["task_ids"][0]


def wait(task_id, timeout=300):
    deadline = time.time() + timeout
    while time.time() < deadline:
        result = request("GET", f"{API}/fetch_tasks_results/", params={"task_id": task_id})
        if result["status"] in ("DONE", "DUPLICATE"):
            return result
        if result["status"] == "FAILED":
            raise RuntimeError(result.get("status_message") or "extraction failed")
        time.sleep(2)  # polling reads are limited to 2 requests per second
    raise TimeoutError(task_id)


if __name__ == "__main__":
    result = wait(upload(sys.argv[1]))
    for name, field in result["General_fields"].items():
        print(f"{name:20} {field['value']!s:45} {field['confidence_score']:.2f}")

We tested the flow against a local mock of the documented endpoints (a 429, then IN PROGRESS, then DONE), not against a live account. A finished task looks like this; the field names are the ones of your template, and the values below are those of the ICAO specimen, not of a real customer document:

{
  "status": "DONE",
  "status_message": "",
  "task_id": "EOBdOSUX4Uv57YWsLYc4JQ",
  "document_id": 12345,
  "document_name": "passport.jpg",
  "General_fields": {
    "Surname":         {"value": "ERIKSSON",   "confidence_score": 0.99},
    "Given names":     {"value": "ANNA MARIA", "confidence_score": 0.98},
    "Document number": {"value": "L898902C3",  "confidence_score": 0.99},
    "Nationality":     {"value": "UTO",        "confidence_score": 0.99},
    "Date of birth":   {"value": "1974-08-12", "confidence_score": 0.98},
    "Date of expiry":  {"value": "2012-04-15", "confidence_score": 0.98},
    "MRZ line 1":      {"value": "P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<", "confidence_score": 0.97},
    "MRZ line 2":      {"value": "L898902C36UTO7408122F1204159ZE184226B<<<<<10", "confidence_score": 0.97}
  },
  "Line_fields": {}
}

Status DUPLICATE means Koncile recognised a file it had already received; the script treats it as finished. A task id lives 24 hours, so store the document_id if you need the result later. Repeated values, such as the lines of a table, would come in Line_fields; a passport has none.

Cross check the API output with your own parser

The most useful script combines options 2 and 3: the API reads the printed zone and the MRZ, your parser recomputes the check digits and compares the two zones. A misread character usually shows up as a broken check digit; a value edited on the printed zone only shows up as a disagreement between the zones. Both send the document to a person, never to an automatic refusal.

from koncile_passport import upload, wait
from mrz_td3 import parse_td3

result = wait(upload("passport.jpg"))
f = {name: v["value"] for name, v in result["General_fields"].items()}
mrz = parse_td3(f["MRZ line 1"], f["MRZ line 2"])

problems = [name for name, ok in mrz["checks"].items() if not ok]
if f["Document number"] != mrz["document_number"]:
    problems.append("printed number differs from MRZ")
if f["Date of birth"] != mrz["date_of_birth"]:
    problems.append("printed birth date differs from MRZ")
low = [n for n, v in result["General_fields"].items() if v["confidence_score"] < 0.90]

print("review by hand" if problems or low else "consistent", problems, low)

Here, the API is the reader and Python is the judge, with rules you can read and test. The confidence threshold of 0.90 is a starting point to tune on your own documents, not a published figure.

Errors you will meet, and what to do

SymptomLikely causeFix
PassportEye returns NoneZone not found: glare, tilt, MRZ cut at the edge, low resolutionCrop the data page, avoid flash reflections, try mrz --legacy
valid_score below 100Tesseract confused O and 0, or « < » and KCorrect by position (digits where the format wants digits), then re-check
307 Temporary RedirectMissing trailing slash on /upload_file/Add the slash
413 Payload Too LargeMore than one file in the request, or file too bigOne file per call; images under 10 MB
402Out of credits or a usage limit reachedCheck Billing & Usage in the app
429 Too Many RequestsMore than the allowed rate for the keyWait for Retry-After seconds, then retry
status FAILEDUnreadable file or extraction errorRead status_message, fix the image, upload again

Image quality: what changes the result most

Whatever the route, the photo decides more than the code. Glare on the laminate erases characters of the MRZ; a page photographed at an angle shortens one line; a crop that cuts the last characters breaks the composite check digit. Ask users for the full data page, flat, without flash, filling most of the frame. If you accept PDFs, a scanner at 300 dpi gives Tesseract enough pixels; a phone photo sent through a messaging app is often compressed below that.

What none of these scripts can prove

A valid MRZ shows that the characters agree with each other. The algorithm is public, so an edited image can carry a perfect zone. Reading the chip (NFC), matching the face with a selfie and checking liveness are separate tools; neither PassportEye, a parser nor Koncile does them. Koncile adds a fraud analysis on the file as a separate add-on, with a score from 0 to 100: a signal to verify, never an accusation. Where that fits in a full onboarding file is described on our KYC OCR page.

Frequently asked questions

01How do I read the MRZ of a passport in Python?

Install PassportEye and Tesseract, then call read_mrz on the image: it returns the MRZ fields and one validity flag per check digit. If you already have the two lines of text, a parser of about 50 lines, like the one on this page, recomputes the check digits without any dependency.

02Is PassportEye still maintained?

Version 2.2.2 was published on PyPI in March 2025 and the GitHub repository still receives merged pull requests. It depends on Tesseract, numpy, scipy and scikit-image, and its README notes that installing them can fail on some systems.

03How accurate is PassportEye?

Its author reports recognition in about 80 % of cases when the MRZ is clearly visible, based on the test documents he had, with failures mostly on blurred scans and some ID cards. There is no independent benchmark; measure it on a sample of your own documents.

04What is an MRZ parser?

A function that takes the lines of a machine readable zone and splits them into named fields (document number, names, dates, nationality, sex) at fixed positions, then recomputes the check digits. It does not read images: it needs the text from an OCR engine or an API first.

05Can Python read the printed fields of a passport, not just the MRZ?

Not with PassportEye, which only reads the zone. You need a general OCR engine plus your own layout rules per country, or an API that returns the printed fields by name, such as Koncile with a passport template.

06Do I need an SDK to call the Koncile API from Python?

No. The documented endpoints work with the requests library, as in the script above. The documentation also mentions a Python SDK; plain HTTP calls keep the dependency list short.

07How much does a passport cost to read with the Koncile API?

One credit per page. A new account gets 20 free credits; paid monthly plans start at 1 000 credits, listed on the Koncile pricing page. The fraud analysis is a separate add-on.

08Can these scripts prove that a passport is genuine?

No. They detect misreadings and inconsistencies between zones. They do not check holograms, the chip or the holder's face. Treat a broken check digit or a mismatch as a reason for a manual review.

Sources

Try the API on your own passports

Create a Koncile account, set up a passport template and run the script above on your documents. 20 free credits, no card, for professional use.

Create a free Koncile accountTalk to an expert

Documents are deleted after analysis. Face match, liveness and chip reading are not part of the service.