A good(?) serial number scheme

Posted on 2026-10-10

In what seems stereotypically German, I've been thinking about how to make a good scheme for product serial numbers a lot over the past years. Here's my current take on a design that basically ticks all my boxes.

First, let's consider when you as a user need to interact with serial numbers:

From these cases, we can deduce some desirable properties for our serial number scheme:

  1. Serial numbers should be reasonably compact (maybe on the order of a dozen digits at worst)
  2. Serial numbers should be easy to read (so no confusion pairs like O/0, B/8, etc.)
  3. Serial numbers should be easily distinguishable (it should be unlikely that you end up with two devices that have serial numbers 124378891 and 124378991). In particular, they should avoid having long common prefixes so that you can abbreviate/tab-complete them easily.
  4. While they should be high-entropy as noted in the previous point, they should strike a balance between this and being decodable without consulting a hypothetical database of all devices
  5. Similarly, they should be offline-checkable for correctness to quickly catch typos during data entry

From the perspective of somebody who issues those serial numbers (i.e. a device manufacturer), you mostly want enough space to encode things in a future-proof manner. To an extent, this runs counter to goals 1 and 2 above. We'll have to strike a reasonable balance.

One final consideration is whether serial numbers should be product-scoped or global. I think having a global namespace is preferable if you can get enough space out of it. This means you don't need a tuple of (manufacturer, device type, serial) to get a unique identifier, but rather "ACME widget with serial XXXXX" is enough.

Let's now walk through the design in a bottom-up fashion.

Character set and check digit

To achieve offline-verifiability of a serial (goal 5), we need to introduce some form of check digit. Many popular schemes exist, like the classic Luhn algorithm or more modern schemes like the Damm algorithm. Unlike other checksum algorithms such as CRCs, they are usually tuned to use their limited error-detection capacity (we normally only want to "waste" a single digit for this) to capture mistakes that humans tend to make, for example single digit errors or transposition errors (123→132).

What these schemes have in common is that they are designed for a specific base (in this case, base 10), so this interacts with our choice of character set. We could of course use base 10, but to squeeze as many bits into our serial number as possible while still balancing that with our readability goals, we can go a little further than base 10.

One particularly attractive check digit scheme I have come across during my research which still seems to be rarely used despite being much better than the more popular ones mentioned above is the one by Chen and Niemenmaa (see e.g. here). It's an elegant construction that produces a very powerful check digit system, but the downside is that it only works well if the base doesn't have a prime factor of 2, 3 or 5 appearing exactly once in its factorization, so it's not that useful for base 10 numbers, which might explain its obscurity (though it does produce a better check digit scheme for ISBNs than the one that was originally specified).

But we can use this constraint to inform our character set selection. Ideally, we want a base that simply is prime itself for the best performance. From a base inventory of digits and letters (36 in total - mixing upper and lower case letters is aesthetically unpleasing and error-prone, we'll stick with upper case), candidate sizes are 11, 13, 17, 19, 23, 29 and 31.

A reasonable length for our serial numbers is 12, since this partitions neatly into three 4-digit (in the following, I'll use "digit" to mean "member of our character set, including letters") groups. Both 3 and 4 are easily within the human chunking capacity, so they should be easy to hold in short-term memory, but they're large enough to avoid creating too many chunks.

12 digits mean 11 digits of payload and a check digit, so the capacities for the different base sizes are:

The smaller the base, the more freedom we have to drop difficult characters, so we should pick the smallest base that satisfies our space requirements. For the reasons mentioned below, I wanted to be able to map arbitrary unicast MAC addresses into serial numbers in addition to other usage, so base 19 is the smallest one that works1.

Thus, our scheme is going to use 11 base-19 digits plus one base-19 check digit. This gives us 1911=11649025889821919^{11} = 116490258898219 unique serial numbers, more than enough to fit everything we're going to need, see below.

Let's start with our base 36 repertoire (0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ:36 - I'll note the current character set size with a colon in the following), which characters should we drop to get to 19?

First, it's common to remove vowels to avoid forming obvious words: 0123456789BCDFGHJKLMNPQRSTVWXYZ:31.

Then, there are letter/number confusion pairs like O/0, 1/7/L/I, B/8, 5/S, 6/G and 2/Z. How easily confusable they are depends on your font, but at this point it doesn't hurt to be conservative. Also, it would be strictly speaking sufficient to eliminate one half of each pair (e.g. just drop 0 or O, and if the other is entered/read to you, you know which one was meant). However, as somebody whose government ID number contains a 0 (or an O? It's hard to tell!), I am perpetually wary when entering it into some system, wondering if it does have the proper matching logic or will just silently fail in some fashion. Thus, to reduce user confusion, it seems prudent to exclude such pairs entirely if at all possible. This leaves us with: 349CDFHJKMNPQRTVWXY:19.

This is already base 19, but there are other issues with this character set: K and X are topologically very similar and might be easy to confuse depending on the font. D can potentially be misread as O/0 again.

After fiddling with this for way too long I've decided to prefer characters which have a distinct topology (e.g. as in topological genus) and if pulling in a pair like K/X is unavoidable, preferring the symmetrical character (X in this case), since this is more likely to be visually obvious as itself in a given font than the alternative. Another guiding principle was preferring characters that tend to gracefully degrade under fading/smudging of the print, which again favors characters with unique features.

In the end, I decided to go with 3479CFGHJMNPQRTVWXY:19, although including 7 was a tough call. After surveying some fonts I've come to the conclusion that it works well enough though.

For separating groups I picked dashes rather than spaces so a serial still presents as a cohesive entity in lists or in text. Thus, our serials now look like this: CQNX-FYJV-9H3P.

We can now give a mapping from integers in the range [0,1911)[0,19^{11}) to such serial numbers, with the only remaining choices being the endianness and the value of α\alpha in the check digit algorithm. α\alpha can be any primitive root of Z19\mathbb{Z}_{19}. Let's just pick the smallest one, 2.

The endianness is somewhat arbitrary too, but given how we're going to allocate values from our pool of integers below, marginally more entropy is going to be in the lower-order bits, so an LSB-first scheme helps concentrate this entropy at the beginning of the serial number, where it is most useful for distinguishing them when reading left-to-right.

Thus, encoding and decoding works like this:

CHARSET = "3479CFGHJMNPQRTVWXY"

def check_equation(digits: list[int]) -> int:
    total = 0
    power = 1

    for d in digits:
        # alpha = 2
        power = (power * 2) % 19
        total = (total + d * power) % 19

    return total

def encode(n: int) -> str:
    if not 0 <= n < 19**11:
        raise ValueError(f"expected 0 <= n < 19**11, got {n}")

    data = []
    for _ in range(11):
        n, d = divmod(n, 19)
        data.append(d)

    # alpha^(-12) mod 19 = 7
    check = (-check_equation(data) * 7) % 19

    s = "".join([CHARSET[x] for x in data])
    s += CHARSET[check]

    return f'{s[:4]}-{s[4:8]}-{s[8:]}'

def decode(serial: str) -> int:
    serial = serial.replace('-', '').upper()
    if len(serial) != 12:
        raise ValueError(f"serial number {serial} has invalid length")

    digits = []
    for ch in serial:
        try:
            digits.append(CHARSET.index(ch))
        except ValueError:
            raise ValueError(f"invalid character {ch} in serial number: {serial}")

    if check_equation(digits) != 0:
        raise ValueError(f"invalid check digit in {serial}")

    n = 0
    for d in reversed(digits[:11]):
        n = n * 19 + d

    return n

Number space allocation

With the exception of approaches to increase entropy, most of the "secret sauce" for building a good serial number scheme has been handled above and the rest is a bit more idiosyncratic and might not fit your needs. However, I'll explain the considerations I made when allocating the address space afforded by this scheme for my own applications in the hope that it will provide useful building blocks for your own applications.

First, it is convenient (or at least more familiar) to carve this space up into powers of two: 1911=246+245+243+…19^{11} = 2^{46} + 2^{45} + 2^{43} + \ldots. We'll allocate these three chunks sequentially as follows:

One important consideration (despite the name) is to make serial numbers look random. This helps to easily distinguish them and prefix match them (this is also useful when talking to somebody and wanting to refer to one of e.g. four devices on their desk. If the serial numbers are basically random, the first 4-digit group of the serial is almost guaranteed to be enough to be unique).

Similar to metans.net, the approach here is to use scrambling. Mathematically, we want a pseudorandom permutation (cryptographically secure or not depending on our needs). In the following we'll use both a simple xor-shift-multiply integer mixer and the Speck cipher.

The probabilistic space doesn't need any kind of scrambling at all. The assumption here is that we use some kind of hash function to derive the ID anyway, which will have enough entropy already.

MAC addresses

The MAC address space however is highly structured (in particular, our addresses will often share an OUI prefix). To keep things simple, we use a basic integer mixing scheme tuned for reasonable avalanche behavior on 46-bit values:

MASK46 = (1 << 46) - 1

def mix46(x: int) -> int:
    if not 0 <= x <= MASK46:
        raise ValueError("x must be a 46-bit unsigned integer")

    x ^= x >> 22
    x = (x * 0x071C85C552AB) & MASK46
    x ^= x >> 20
    x = (x * 0x2979239AB399) & MASK46
    x ^= x >> 23

    return x

def unmix46(x: int) -> int:
    if not 0 <= x <= MASK46:
        raise ValueError("x must be a 46-bit unsigned integer")

    x ^= x >> 23
    x = (x * 0x11FC3A80F0A9) & MASK46
    x ^= (x >> 20) ^ (x >> 40)
    x = (x * 0x1BC64CD01803) & MASK46
    x ^= (x >> 22) ^ (x >> 44)

    return x

So for example, the MAC address 00:11:22:33:44:55 becomes:

>>> m = int("001122334455", 16)
>>> x = ((m >> 42) << 40) | (m & ((1 << 40) - 1))
>>> encode(2**43 + mix46(x))
'YHYC-PVMP-RNNJ'

Central allocation

We split the 45-bit space into 16 bits for a product ID and 29 bits for serials within that product. This is more than enough for almost all conceivable applications. To once again ensure high entropy, we mix the resulting identifiers (otherwise multiple instances of the same product would share many digits). For this, we can reuse mix46 with cycle walking. Since 245=246/22^{45} = 2^{46}/2, we expect two iterations of the mixing function, which still is trivially cheap.

How we allocate serials within the product space depends a lot on the product characteristics. For example, we could use date-based or sequential/batch-based schemes. This in turn depends a lot on how your manufacturing is structured and is out of scope for this article.

But one consideration worth mentioning is whether you want to expose such internal information to third parties. I'd argue that being able to derive a product ID from a serial number as a third party is a useful property. But maybe you want to hide the remaining 29 bits that might expose how many devices you have built or how your manufacturing lines are structured. For this, you can use a cipher such as Speck which supports small block sizes. In this case, you'd use the 32bit version and cycle-walk again (with 8 iterations expected). Another approach is to use one of the other FPE schemes in existence.

Similarly, if you want to hide the product ID too, cycle-walking Speck48 for the entire 45 bits also is feasible.

Conclusion

So this is my overengineered scheme for generating all the serial numbers I'll ever need and that hopefully fixes all of the complaints I have about other serial numbers out there in the world.

Feel free to reuse (any parts of) this. As always, I'm happy to hear your thoughts.

  1. MAC addresses are 48 bits, but 2 of those are for distinguishing between unicast/multicast and locally/globally administered. In our case, only globally administered unicast addresses are relevant, so 46 bits are enough. ↩︎