Passport OCR in Python: PassportEye, an MRZ parser or an API
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.

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.
| Route | What you get | What you install | Main limit |
|---|---|---|---|
| PassportEye + Tesseract | The MRZ only: type, country, number, names, dates, sex, check digit flags | pip package, numpy, scipy, scikit-image, the tesseract binary | Its author reports about 80 % recognition when the MRZ is clearly visible; 10 s or more on some images |
| Your own MRZ parser | Exactly the fields and checks you write, from two or three lines of text | Nothing beyond Python | Needs 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 value | requests and an API key | Paid 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 stringsWhat 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.
| Field | Line 2 positions (TD3) | Check digit at | Notes |
|---|---|---|---|
| Document number | 1 to 9 | 10 | Fillers « < » pad numbers shorter than 9 |
| Nationality | 11 to 13 | none | Three letter code, D for Germany |
| Date of birth | 14 to 19 | 20 | YYMMDD, unknown parts may be « < » |
| Sex | 21 | none | F, M, or « < » when unspecified |
| Date of expiry | 22 to 27 | 28 | YYMMDD |
| Personal number | 29 to 42 | 43 | Check digit may be « < » or 0 when empty |
| Composite | 1 to 10, 14 to 20, 22 to 43 | 44 | Covers 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
| Symptom | Likely cause | Fix |
|---|---|---|
| PassportEye returns None | Zone not found: glare, tilt, MRZ cut at the edge, low resolution | Crop the data page, avoid flash reflections, try mrz --legacy |
| valid_score below 100 | Tesseract confused O and 0, or « < » and K | Correct by position (digits where the format wants digits), then re-check |
| 307 Temporary Redirect | Missing trailing slash on /upload_file/ | Add the slash |
| 413 Payload Too Large | More than one file in the request, or file too big | One file per call; images under 10 MB |
| 402 | Out of credits or a usage limit reached | Check Billing & Usage in the app |
| 429 Too Many Requests | More than the allowed rate for the key | Wait for Retry-After seconds, then retry |
| status FAILED | Unreadable file or extraction error | Read 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
- PassportEye on GitHub (README, MIT license), read on 3 November 2026
- PassportEye on PyPI (version 2.2.2)
- ICAO Doc 9303, part 3: check digits and common specifications
- ICAO Doc 9303, part 4: machine readable passports (TD3)
- Koncile API: getting started, file uploading, task retrieval, status codes
- Tesseract OCR
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.