Randomly

Identifiers

ABA routing numbers: the 3-7-1 checksum banks still use

Bipul Kumar 8 min
ABA routing numbers: the 3-7-1 checksum banks still use
On this page 0%

I have a soft spot for the ABA routing number because it looks like a random 9-digit code until you watch a $0.01 micro-deposit fail with “invalid routing.” The check is one digit. The weights are 3, 7, and 1. Someone in 1910 wanted clerks to catch a transposition before the paper went in a bag. We still use that catch in ACH files that move rent.

I generate checksum-valid routing numbers for fixtures, and I validate them in the same tab. The routing number generator and the routing number validator are not a bank. They will not tell you whether a credit union in Ohio still uses that RTN. This post is the 3-7-1 walk, the 9-digit shape, and how I keep ACH test files from looking like a production origination.

Nine routing digits with repeating 3-7-1 weights and a check digit on the right
Positions 1-8 get weights 3, 7, 1, 3, 7, 1, 3, 7. Position 9 makes the weighted sum a multiple of 10.

Nine digits and the MICR line

An ABA routing transit number is 9 digits. You see it at the bottom left of a US check, in MICR font, with a transit symbol. Online, it is a numeric field that people copy wrong. Length is the first check. Leading zeros matter. Some banks start with 0. If you store the number as an integer, you drop the zero and then fail checksum and fail the Fed. Store a string. I have fixed this in more codebases than I want to admit. JavaScript users: do not parseInt the routing number. Ever.

The first two digits relate to the Federal Reserve routing territory in the original scheme. 12 is San Francisco’s district in that old map. Numbers were reused and reassigned as banks merged. Do not write a geography decoder and call it fraud prevention. The only cheap math you can do locally is the check digit. Eligibility to receive ACH is a directory problem (Fed ACH directory, vendor APIs), not a modulus problem.

Canadian routing numbers are a different format. IBAN is a different continent. If your form says “routing number” and a user pastes an IBAN, reject on alphabet, do not run 3-7-1 on letters. Point SEPA users at an IBAN validator, not at 3-7-1. I keep those tools separate so the checksum code cannot be mixed. Mixing is how you get a function named isValidBankId that is wrong for every country.

The 3-7-1 checksum

Label the digits d1 through d9. Compute 3*d1 + 7*d2 + 1*d3 + 3*d4 + 7*d5 + 1*d6 + 3*d7 + 7*d8 + 1*d9. The total must be divisible by 10. Equivalently, choose d9 so that the sum of the first eight weighted digits, plus d9, ends in 0. (The last weight is 1, so d9 is a straight correction to the last digit of the running sum.)

Worked toy: I always re-derive d9 in tests rather than memorizing a magic RTN. Take eight digits, compute S = 3d1+7d2+d3+3d4+7d5+d6+3d7+7d8, then d9 = (10 - (S mod 10)) mod 10. Then run the full nine through the validator. If generate and validate disagree, stop. Shared bugs are the enemy, same as Luhn test cards.

The weights catch many adjacent transpositions because 3 and 7 are distinct. Not all. It is a check digit, not a hash. A wrong number can still land on a multiple of 10. That is fine. You wanted a filter for typos, not a proof of account existence.

ACH file header cartoon with routing numbers in a batch and a stamp that says fixture
Checksum-valid RTNs belong in fixture files. They do not belong in a live origination batch.

Generating fixtures

Pick eight random digits (or a prefix you like for a fake district), compute d9, print nine characters. Do not pick a well-known real bank’s routing number “so it looks right.” Those numbers are in every ACH textbook and every phishing kit. They also belong to institutions that will receive misrouted files if your staging originator is misconfigured.

I have used 021000021 in demos because it is a famous Chase routing number and I regretted it when a screenshot hit Slack search. Generate a number that passes the check and is not in your production allowlist. If your app checks against a downloaded Fed directory, your generator output will fail that check. Good. That is a different layer. Unit-test the checksum with generated numbers. Contract-test the directory with a mocked file that includes one synthetic RTN you inserted, not with the live Fed list in CI.

For account numbers next to routing numbers, US demand deposit account numbers have no universal check digit. People invent Luhn on DDA and then fail real accounts. Do not. If you need a second field to fill, random digits of a plausible length (9 to 12) are enough for UI tests. Label them synthetic.

What the checksum will not save

It will not tell you the bank merged. It will not tell you the RTN is wire-only versus ACH. It will not tell you the account is open. Nacha return codes exist because the checksum passed and the rest failed.

It will not make a number safe to publish. A routing number is not a secret, exactly. It is printed on every check. Combined with a name, account number, and amount, it is a payment instruction. Test files with realistic names plus a routing plus an account are one accidental S3 bucket away from looking like a live batch. Read PII-safe test data before you build that CSV.

Wire routing versus ACH routing: some banks publish two numbers. Your form might need a toggle. The checksum algorithm is the same shape. The directory meaning is not. I do not encode that toggle in the generator. I encode nine digits that pass 3-7-1.

Routing number generator and validator with a nine-digit output and a pass badge
Nine digits, 3-7-1, local. No Federal Reserve on the other end of this page.

Validation in a form

Strip spaces and dashes. Confirm nine digits. Run 3-7-1. Then, if you have a directory, look up. Show a kind error for checksum failure (“this routing number looks mistyped”) and a different error for directory miss (“we do not recognize this bank”). Collapsing those confuses users who transposed a digit versus users whose credit union is too new for your cached list.

Do not Luhn a routing number. I have seen a shared luhn(value) on every numeric ID. ABA is not Luhn. VIN is not Luhn. IBAN is not Luhn. Cards are Luhn. That generator is the other tab.

International users will paste sort codes, BSB, or IBANs. Detect alphabet early. A 22-character German IBAN will fail “must be 9 digits” and that message should say so. Offer a link to IBAN tools if your product actually moves EUR. If it does not, say you only ACH to US banks. Honesty beats a checksum.

ACH files and staging

If you originate ACH, staging should hit a sandbox or a file you never send. Checksum-valid routing numbers make Nacha formatters happy. They do not make the Fed happy if the file escapes. I put TEST in company names and I use obviously fake account numbers. I do not use a real employee’s routing “because we already pay them.” That is how you double-pay an employee at 3 a.m. from a debug script.

ODFI and RDFI identification in the file batch headers have their own rules. I am not replacing your Nacha library. I am telling you the routing field inside an entry will often be schema-validated with 3-7-1 before anything else. Get that function right and tiny. Test with a known-good published example and a known-bad where you flip d9.

An IBAN generator is the European sibling. Same fixture philosophy. Different arithmetic. If you are a US-only app, you still might need IBANs later. Keep the code paths named after the standard, not after “bankNumber.”

I run our validator on my generator output every time I change either file. That is a unit test, not a vibe. I also keep 111000025 (a commonly cited test-ish example in some docs) out of production allowlists if I can. Famous numbers attract copy-paste. Famous numbers also belong to real institutions in some tables. Prefer random, checksummed, labeled synthetic.

Paper checks still exist. MICR readers will reject a routing number that a web form accepted if you allowed spaces in storage. Compact at rest. Display with the transit symbols only if you are drawing a check, which most SaaS apps are not. For an onboarding form, nine boxes or one field with an input mask is enough. Input masks that skip the check digit until blur will fight autofill. I prefer one field, strip, validate on blur.

If your product also collects a card for the same customer, keep the PAN test data on the Luhn path. I link those suites in QA docs so a new engineer does not run ABA math on a Visa prefix. The tools sit next to each other on Randomly. The algorithms do not sit next to each other in one function.

Frequently asked questions

Does a valid checksum mean I can ACH this bank?

No. It means the 9 digits are internally consistent. Directory and bank status are separate.

Why 3, 7, and 1?

Historical ABA weights. They are easy to compute by hand and catch many clerk errors. I did not pick them. I just implement them.

Can I store routing numbers as numbers in SQL?

You can. You will drop leading zeros. Use char(9) or text. Your future self will send a fruit basket.

Is Randomly a bank directory?

No. The pages mint and check the modulus locally. For live eligibility, use the Fed’s materials or your processor. See privacy if you wondered whether we keep the numbers you generate (we do not).