Django, FastAPI, and data-import jobs often need a small Python helper before a row is accepted. This module uses datetime.date and calendar.monthrange so impossible dates fail fast, then applies CheckID's citizenship and Luhn rules. It does not confirm that a person exists on a government register.
8001015009087 is a fictional, structurally valid demonstration number used throughout CheckID docs. Do not treat tutorial IDs as real people.
The algorithm, in the order CheckID applies it
A South African ID number is a 13-digit code defined in regulation 3 of the Identification Regulations, 1998. CheckID validates that structure in memory. The snippets on these pages follow the same order as validateIDNumber in production: date, gender sequence, citizenship, then Luhn. Digit 12 (the population-register index, usually 8) is not used as a validity gate.
| Positions | Segment | What to check |
|---|---|---|
| 1–6 | YYMMDD | Must be a real calendar date after century inference |
| 7–10 | SSSS | 0000–4999 female, 5000–9999 male (use the first of these four digits) |
| 11 | C | 0 SA citizen, 1 non-SA citizen, 2 refugee — reject anything else |
| 12 | A | Population-register index (usually 8). Not a fail/pass rule here |
| 13 | Z | Luhn check digit over the first 12 digits |
Date of birth and century inference
Digits 1–6 are YYMMDD, not a four-digit year. CheckID infers the century from today's two-digit year: if YY is less than or equal to the current year's last two digits, the year is 20YY; otherwise it is 19YY. In 2026 that means 26 becomes 2026 and 27 becomes 1927. Impossible dates such as 31 February are rejected before a Date object is trusted. Encoded dates in the future (age below zero) are also rejected.
That two-digit year rule is a real limitation: a person born in 1926 and a child born in 2026 share the same YY. The snippets document the same rule CheckID uses rather than inventing a third century.
Citizenship digit
Position 11 must be 0 (SA citizen), 1 (non-SA citizen / permanent resident encoding), or 2 (refugee). Any other digit fails validation even if the date and checksum look plausible.
Luhn checksum
Using 0-based indexes, double the digits at positions 1, 3, 5, 7, 9 and 11 (the 2nd, 4th, … 12th digits). If doubling produces two digits, add those digits together. Sum the undoubled odd-position digits (indexes 0, 2, 4, 6, 8, 10). The check digit is (10 − (total mod 10)) mod 10 and must equal digit 13. A full worked example for 8001015009087 is in the SA ID checksum guide. Changing only the last digit to produce 8001015009086 fails Luhn while the birth-date segment still reads as 1980-01-01.
Edge cases the snippets must catch
- Length and characters: after stripping spaces and hyphens, the value must be exactly 13 digits. Letters, punctuation, and scientific-notation damage from spreadsheets all fail. CheckID's hosted API expects a 13-digit path segment — do not send spaces in the URL.
- Impossible dates: month 13, day 32, 31 April, and 29 February in a non-leap year (for example YY
99→ 1999). Leap-year 29 February in 2000 is valid. - Citizenship: digits other than 0, 1, or 2.
- Luhn failures: transposed digits and a mistyped final digit, which are the usual keyboard errors.
- Whitespace and formatting: the local snippets strip spaces and hyphens as a courtesy. Production CheckID still requires 13 digits after your own normalisation.
For the digit map in more detail, use the South African ID number structure guide or the interactive explainer.
Working Python source
Pair the snippet with the synthetic fixtures on the SA ID validator and test cases page if you are writing unit tests. Copy the function below. It strips spaces and hyphens, then applies CheckID's date, citizenship, and Luhn rules. Keep it in your repo only if you are prepared to maintain century inference and leap-year behaviour yourself.
python
"""Structural South African ID validation (Python 3.10+).
Example ID 8001015009087 is fictional and labelled for demonstration only.
Do not log or persist ID numbers.
"""
from __future__ import annotations
import re
from calendar import monthrange
from datetime import date
CITIZENSHIP_LABELS = {0: "SA Citizen", 1: "Non-SA Citizen", 2: "Refugee"}
def validate_south_african_id(value: str, today: date | None = None) -> dict:
today = today or date.today()
id_number = re.sub(r"[\s-]", "", value)
if not re.fullmatch(r"\d{13}", id_number):
return {"isValid": False, "error": "Enter exactly 13 digits."}
yy = int(id_number[0:2])
month = int(id_number[2:4])
day = int(id_number[4:6])
year = (2000 if yy <= today.year % 100 else 1900) + yy
if month < 1 or month > 12 or day < 1 or day > monthrange(year, month)[1]:
return {"isValid": False, "error": "YYMMDD is not a real calendar date."}
dob = date(year, month, day)
age = today.year - year - ((today.month, today.day) < (month, day))
if age < 0:
return {"isValid": False, "error": "Encoded date of birth is in the future."}
gender = "Female" if int(id_number[6]) <= 4 else "Male"
citizenship = CITIZENSHIP_LABELS.get(int(id_number[10]))
if citizenship is None:
return {"isValid": False, "error": "Citizenship digit must be 0, 1, or 2."}
digits = [int(ch) for ch in id_number]
even_sum = sum(sum(int(d) for d in str(digits[i] * 2)) for i in (1, 3, 5, 7, 9, 11))
odd_sum = sum(digits[i] for i in (0, 2, 4, 6, 8, 10))
check_digit = (10 - ((even_sum + odd_sum) % 10)) % 10
if digits[12] != check_digit:
return {"isValid": False, "error": "Luhn checksum failed."}
return {
"isValid": True,
"id": id_number,
"dateOfBirth": dob.isoformat(),
"age": age,
"gender": gender,
"citizenship": citizenship,
"error": None,
}
# Demonstration only — fictional structurally valid ID
# print(validate_south_african_id("8001015009087"))
Python-specific notes
datetime.date, not datetime
Date of birth is a calendar date. Using naive datetime objects invites timezone shifts that can move 1 March to 28 February. The snippet keeps date objects only.
Tests and freezegun
Century inference depends on today's two-digit year. Freeze the clock in tests when you assert ages or the 19xx/20xx split.
Logging
Do not interpolate the ID into logging.info or traceback extras. Python's logging module will persist that string. CheckID does not store ID numbers on its side; keep that boundary in your workers too.
Use CheckID's API instead of owning the snippet
A local function is enough for learning and for small internal tools. It becomes a liability when leap-year bugs, century collisions, or a copied blog snippet drift from the rules your product claims to use. CheckID exposes the same structural validation as a maintained REST endpoint: GET https://api.checkid.co.za/api/v1/validate/{idNumber} with Authorization: Bearer <token>. The JSON fields (isValid, dob, age, gender, citizenship) are documented in the developer guide — this page does not invent a second contract.
API Starter is R99/month for 1,000 single production calls. Professional is R299/month for bulk and team workflows, subject to fair usage. A free development test key is available with an account. Processing is in memory over TLS; CheckID does not store the ID number or the decoded result.
python · CheckID API
import requests
response = requests.get(
f"https://api.checkid.co.za/api/v1/validate/{id_number}",
headers={"Authorization": f"Bearer {api_key}"},
)
result = response.json()
# result["isValid"], result["dob"], result["age"], result["gender"], result["citizenship"]Walk through authentication and bulk requests in the 5-minute API tutorial, then compare quotas on pricing. Privacy-minded teams should also read POPIA and ID validation.
Other languages in this series
The algorithm above is shared. Each language page keeps its own title, runtime notes, and copy-paste source:
- Validate a South African ID number in JavaScript (Browser and Node.js)
- Validate a South African ID number in C# (.NET 6+)
- Validate a South African ID number in PHP (PHP 8+)