Handwritten Parcel Code System: Codes People Can Write and OCR Can Read
← All posts
GuideNov 7, 2026· 9 min read

Handwritten Parcel Code System: Codes People Can Write and OCR Can Read

If you are designing a handwritten parcel code system, you probably have parcels that get a code written on them by hand before any printed label exists: drop-offs at a counter, returns that arrive in reused boxes, inbound cartons a supplier marked with a marker pen, or sorting bins a driver labels on the floor. Short answer: do not try to make OCR better at reading arbitrary handwriting. Make the code easier to read instead. Use a short, fixed format, an alphabet without look-alike characters, and a check digit. Then read the photo with OCR, normalize the reading by position, and accept it only when the check digit passes and the code matches a parcel you actually expect. Everything else goes to a person with the photo.

This post is about the code itself and the validation around it. For printed carrier labels, tracking numbers, and barcode cross-checks, read shipping label OCR. For why OCR output goes wrong in general, read why is OCR so bad.

What the postal service learned about handwriting

The United States Postal Service has read handwritten addresses at scale for three decades, and its history is a useful design brief. According to a USPS release from 2013, its mail processing equipment was reading nearly 98 percent of hand-addressed letter mail and 99.5 percent of machine-printed mail. Images the machines could not resolve went to Remote Encoding Centers, where operators keyed the address by hand. At the peak in 1997, 55 of those centers processed 19 billion images a year. The last one, in Salt Lake City, still receives about 2.3 million address images a day.

Two lessons carry over to a parcel code system of any size:

  • Recognition works best against a closed answer set. Postal readers do not just transcribe; they match what they read against a database of valid addresses. Your equivalent is the list of parcels you expect today.
  • A human queue is part of the design, not a failure. Even after decades of investment, the hard images go to people. Plan the review step and the photo it needs from day one.

Design the code before you pick an OCR tool

Most of the accuracy in a handwritten code system is decided when you choose the format. A few rules do most of the work.

Keep it short and fixed-length. Seven characters is plenty for most sites. A fixed length means a missing or extra character is caught by shape alone, before any lookup.

Restrict the alphabet. Handwritten O and 0, I and 1, S and 5, Z and 2, and B and 8 are where most misreads come from, for people and for software. Douglas Crockford’s Base 32 encoding is a good reference: it drops I, L, O, and U from its symbol set because the first three are easily confused with 1 and 0, and when decoding it treats i and l as 1 and o as 0. For handwriting you can go further. The example below allows only 14 letters (A C E F H K M N P R T W X Y) and only in the first position. Every other position is a digit.

Give each position one job. If position one is always a letter and positions two to seven are always digits, a reading of O in a digit position can safely become 0, and S can become 5. That position-aware correction is impossible in a free-form code.

Add a check digit. A check digit turns many silent errors into detectable ones. The Damm algorithm detects all single-digit errors and all adjacent transpositions, uses only digits (no X like the old ISBN-10), and needs a single 10 by 10 lookup table. Its one documented weakness is that leading zeros do not change the check digit, which is harmless when every code has the same length.

Make it easy to write. Print boxes or a comb field on the intake sticker, one character per box, with a dash printed between groups. Ask for block capitals. Separate groups are easier to read back and easier for OCR to segment.

Putting it together, a code looks like K-48271-1: a zone or route letter, a five-digit serial, and a Damm check digit over the serial.

Issue codes and resolve readings in Python

The script below does both halves. make_code issues a code from a zone letter and a five-digit serial. resolve takes a raw OCR reading and a list of codes you expect today, and returns ok, corrected, or review.

import itertools
import json
import re
import sys

# Damm operation table (weakly totally anti-symmetric quasigroup, zeros on the diagonal)
DAMM = [
    [0, 3, 1, 7, 5, 9, 8, 6, 4, 2],
    [7, 0, 9, 2, 1, 5, 4, 8, 6, 3],
    [4, 2, 0, 6, 8, 7, 1, 3, 5, 9],
    [1, 7, 5, 0, 9, 8, 3, 4, 2, 6],
    [6, 1, 2, 3, 0, 4, 5, 9, 7, 8],
    [3, 6, 7, 4, 2, 0, 9, 5, 8, 1],
    [5, 8, 6, 9, 7, 2, 0, 1, 3, 4],
    [8, 9, 4, 5, 3, 6, 2, 0, 1, 7],
    [9, 4, 3, 8, 6, 1, 7, 2, 0, 5],
    [2, 5, 8, 1, 4, 3, 6, 7, 9, 0],
]

# Zone letters people write clearly and OCR rarely confuses with digits
ZONES = "ACEFHKMNPRTWXY"

# What a misread usually looks like in a position that must be a digit
AS_DIGIT = {"O": "0", "D": "0", "Q": "0", "I": "1", "L": "1", "|": "1",
            "Z": "2", "S": "5", "G": "6", "B": "8", "T": "7"}

# Digit positions where more than one reading is plausible
AMBIGUOUS = {"1": "17", "7": "71", "4": "49", "9": "94", "5": "56", "6": "65"}


def damm_check(digits: str) -> int:
    interim = 0
    for d in digits:
        interim = DAMM[interim][int(d)]
    return interim


def make_code(zone: str, number: str) -> str:
    """Issue a code: zone letter, 5 digits, Damm check digit."""
    assert zone in ZONES and re.fullmatch(r"\d{5}", number)
    return f"{zone}-{number}-{damm_check(number)}"


def normalize(raw: str) -> str | None:
    """Turn an OCR reading into ZONE + 6 digits, or None if the shape is wrong."""
    chars = re.sub(r"[\s\-_.·]", "", raw.upper())
    if len(chars) != 7:
        return None
    zone, rest = chars[0], chars[1:]
    if zone not in ZONES:
        return None
    digits = "".join(AS_DIGIT.get(c, c) for c in rest)
    return zone + digits if digits.isdigit() else None


def resolve(raw: str, open_codes: set[str]) -> dict:
    code = normalize(raw)
    if code is None:
        return {"raw": raw, "status": "review", "reason": "not a parcel code shape"}
    zone, body = code[0], code[1:]
    if damm_check(body) == 0 and code in open_codes:
        return {"raw": raw, "code": f"{zone}-{body[:5]}-{body[5]}", "status": "ok"}
    # Try plausible alternative readings for each digit; keep those that
    # pass the check digit AND are parcels we actually expect today.
    options = [AMBIGUOUS.get(d, d) for d in body]
    matches = sorted({zone + "".join(p) for p in itertools.product(*options)
                      if damm_check("".join(p)) == 0 and zone + "".join(p) in open_codes})
    if len(matches) == 1:
        m = matches[0]
        return {"raw": raw, "code": f"{m[0]}-{m[1:6]}-{m[6]}", "status": "corrected"}
    reason = "check digit fails" if damm_check(body) else "valid code, not expected today"
    if len(matches) > 1:
        reason = f"{len(matches)} expected codes fit this reading"
    return {"raw": raw, "status": "review", "reason": reason}


if __name__ == "__main__":
    open_codes = {c.replace("-", "") for c in json.load(open(sys.argv[1]))}
    for raw in sys.argv[2:]:
        print(json.dumps(resolve(raw, open_codes)))

It needs Python 3.10 or newer and nothing outside the standard library. The Damm table is the one from the Wikipedia article linked above; as a sanity check, damm_check("572") returns 4, matching the worked example there.

Save it as parcel_code.py, put today’s expected codes in open_codes.json, and pass it a handful of readings. These are typed test strings standing in for OCR output, chosen to show each path:

echo '["K-48271-1", "R-05731-6", "M-19364-7", "H-61407-6"]' > open_codes.json

python3 parcel_code.py open_codes.json \
  "K-48271-1" "R O5731 6" "M-79364-7" "K 48217 1" "C-12345-9" "H-61407-3" "K4827"
{"raw": "K-48271-1", "code": "K-48271-1", "status": "ok"}
{"raw": "R O5731 6", "code": "R-05731-6", "status": "ok"}
{"raw": "M-79364-7", "code": "M-19364-7", "status": "corrected"}
{"raw": "K 48217 1", "code": "K-48271-1", "status": "corrected"}
{"raw": "C-12345-9", "status": "review", "reason": "valid code, not expected today"}
{"raw": "H-61407-3", "status": "review", "reason": "check digit fails"}
{"raw": "K4827", "status": "review", "reason": "not a parcel code shape"}

Each line shows a different safeguard:

  • R O5731 6 has a letter O where a digit belongs. Position-aware normalization turns it into 0, the check digit passes, and the code is on today’s list.
  • M-79364-7 is a 1 read as 7. The check digit fails, the script tries the plausible alternatives, and exactly one passes the check and is expected.
  • K 48217 1 is a transposition of the last two serial digits. Same path, same single match.
  • C-12345-9 is a perfectly valid code that nobody expects today. It might be a misread that happened to pass the check, or a parcel that arrived at the wrong site. Either way a person should look.
  • H-61407-3 fails the check and no plausible alternative fits. Review.
  • K4827 is the wrong length. Review, without any lookup.

Adapt the AMBIGUOUS and AS_DIGIT maps to your own data. They are a starting point based on common confusions, not a measured list. After a few weeks, count which corrections the review queue actually makes and update the maps from that.

Read the code from a photo

The OCR step is the simplest part once the code is constrained. Take one photo of the code area at intake, run it through an OCR engine that handles handwriting, and pull candidates out of the text with a pattern that matches your format loosely enough to catch common misreads:

import re

CANDIDATE = re.compile(r"\b[A-Z][\s\-]?[0-9OIDLSZBGQT|]{5}[\s\-]?[0-9OIDLSZBGQT|]\b")


def candidates(ocr_text: str) -> list[str]:
    return [m.group(0) for m in CANDIDATE.finditer(ocr_text.upper())]

With OCRskill, POST /ocr returns Markdown for a photo or scan, using the same Bearer-token auth as the rest of the API, and a free key from get-key.json is enough to try it:

export API_KEY="sk-your-key-here"

curl https://api.ocrskill.com/ocr \
  -H "Authorization: Bearer $API_KEY" \
  -F "file=@parcel-photo.jpg" > parcel.md

Feed each candidate from parcel.md into resolve. If a photo yields zero candidates, or more than one candidate resolves to a different code, send it to review. There is no parcel code field in the typed OCR JSON API catalog, so read the code from Markdown rather than inventing a field name, which returns 400.

A printed-text engine is the wrong tool here. The Tesseract FAQ says plainly that it is designed for printed text and will not work very well on handwriting. Whatever engine you use, test it on photos of your own staff’s handwriting before you rely on it.

The intake flow around it

A handwritten code is a bridge to a printed one, so keep the bridge short:

  1. Issue codes from the system, not from memory. Pre-print serial numbers with check digits on the intake sticker and have people copy only when they must. Copying a printed code is far more reliable than inventing one.
  2. Photograph once, at a fixed spot. A capture station with even light and a fixed distance beats phone photos taken on the floor.
  3. OCR, normalize, check, look up. Exactly the steps above. Log the raw reading, the final code, and the status.
  4. Print a barcode as soon as the code is confirmed. From that point on, scanning replaces reading, and the handwriting only matters for audit.
  5. Review with context. Show the reviewer the photo, the raw reading, the reason, and the short list of expected codes for that zone. Let them confirm or type the code, then print the label.

If the photos should also land in a searchable archive with the rest of the dock paperwork, the logistics paperless document workflow covers folders, naming, and Paperless-ngx.

Limitations to plan for

  • The check digit covers the serial, not the zone letter. A misread zone letter is caught only by the restricted letter set and the lookup against expected codes. If zone errors are costly, encode the zone as digits and include it in the check.
  • Correction is a guess with guardrails. The script corrects only when exactly one expected code fits. That keeps the risk small, not zero. Audit a sample of corrected results every week.
  • The ambiguity maps are not measured. They reflect common confusions, not your writers or your OCR engine. Replace them with what your review queue shows.
  • A closed list is required for the strongest check. Without a list of expected parcels, you still get shape and check-digit validation, but a valid-looking misread can slip through.
  • People will drift from the format. Lowercase, crossed sevens, extra dashes, and codes written across a box seam all happen. The normalization handles some of it; training and pre-printed boxes handle more.

Before you trust any accuracy figure, measure your own. Take a hundred real parcel photos, run them through the pipeline, and count ok, corrected, and review. Then have someone check every corrected result by hand. That tells you whether the corrections are safe for your site and how big the review queue will really be.

Where OCRskill fits

OCRskill handles the reading step: a parcel photo in, Markdown out. It does not issue codes, compute check digits, know which parcels you expect, or print labels. The format, the validation, the expected-codes list, and the review decision live in your own code and your WMS, which is where they belong, because those are the parts that make handwriting safe to rely on.

Conclusion

A handwritten parcel code system succeeds or fails on the code design, not on the OCR model. A seven-character format with one letter, a restricted alphabet, and a Damm check digit turns most misreads into either a confident correction or a clear review case. Matching against the parcels you expect today, as postal systems match against valid addresses, catches most of what remains. Start by redesigning the intake sticker with printed boxes and pre-issued codes, run a week of photos through the resolver above, and let the review queue tell you which rules to tighten.

To try the reading step, get a key from get-key.json and run a photo of one of your hand-coded parcels through /ocr. Product details and pricing are at ocrskill.com.