NACHA Record Layouts Explained: Byte-Level Parsing & Exception Routing
When a NACHA ACH file lands on your ingestion boundary, every byte is load-bearing. A single misaligned field, an unbalanced control total, or an out-of-order record type will either post the wrong dollar amount to the wrong account or silently drop entries from settlement. This guide dissects the 94-byte fixed-width record model position by position and shows how to parse, validate, and route it deterministically — sitting within the broader Core Architecture & Payment File Standards framework that governs how this institution ingests, validates, and audits payment files at scale.
Getting the layout wrong is not a cosmetic bug. Because NACHA records are positional rather than delimited, an off-by-one slice corrupts the routing number, the trace number, and the amount simultaneously — and none of it raises an exception on its own. The parsing discipline here builds directly on generic fixed-width file decoding but adds the NACHA-specific record grammar, control-total arithmetic, and return-code semantics that separate a toy reader from an audit-ready reconciliation engine. Downstream, the trace numbers and amounts extracted here become the reconciliation key consumed by the transaction matching & reconciliation layer.
Concept Definition: The 94-Byte Record Grammar
A NACHA file is a strictly ordered stream of 94-character ASCII records, each terminated by a line break. Whitespace is structural: unused positions are space-padded (alphanumeric fields) or zero-padded (numeric fields), and the parser must treat both as significant. Records are identified by a single Record Type Code in position 1 and must appear in a rigid hierarchy:
- File Header (
1) — one per file: origin/destination routing, creation date/time, File ID Modifier. - Batch Header (
5) — one per batch: Service Class Code, SEC code, company identifiers, effective entry date. - Entry Detail (
6) — one per transaction: RDFI routing, account number, amount, individual name, trace number. - Addenda (
7) — optional, attached to an entry: remittance or return/notification-of-change information. - Batch Control (
8) — one per batch: entry/addenda counts, entry hash, debit and credit totals. - File Control (
9) — one per file: batch count, block count, aggregate counts and totals.
The core numeric fields carry an implied decimal: the Amount field (Entry Detail positions 30–39) is a 10-digit integer count of cents, so 0000012345 means $123.45. Because the format is positional and read exactly once per record, a correct parser runs in time over the byte length of the file with resident memory — no record depends on any record other than the currently open batch context.
Entry Detail field map (Record Type 6)
| Positions | Length | Field | Notes |
|---|---|---|---|
| 1 | 1 | Record Type Code | Always 6 |
| 2–3 | 2 | Transaction Code | 22/27 checking credit/debit, 32/37 savings credit/debit, 23/33 prenote |
| 4–11 | 8 | RDFI Routing (first 8 digits) | ABA routing number sans check digit |
| 12 | 1 | Check Digit | Completes the 9-digit routing number |
| 13–29 | 17 | DFI Account Number | Left-justified, space-padded |
| 30–39 | 10 | Amount | Integer cents, zero-padded (implied 2 decimals) |
| 40–54 | 15 | Individual Identification Number | Payer/receiver reference |
| 55–76 | 22 | Individual Name | Receiver name |
| 77–78 | 2 | Discretionary Data | ODFI-defined |
| 79 | 1 | Addenda Record Indicator | 1 if a 7 record follows |
| 80–94 | 15 | Trace Number | ODFI routing (8) + sequence (7); the reconciliation anchor |
The full nine-digit routing number spans positions 4–12 and must satisfy the ABA weighted-modulus checksum:
Architecture: Where Record Parsing Fits the Pipeline
NACHA files never arrive as trusted local files. They cross an encrypted transport boundary — SFTP, AS2, or mTLS HTTPS with PGP envelope verification — before the parser is ever invoked. That decryption-and-integrity stage is deliberately decoupled from record parsing so that a malformed or tampered payload can never reach the state machine; the transport contract is documented in Secure File Transfer Protocols for Banks. Only after the file hash is recorded and the signature verified does the byte stream enter the parser.
Inside the parser, a finite state machine tracks the expected next record type. It advances only when the current record matches the anticipated state; any deviation (a 6 before a 5, a 9 while a batch is still open) is a hard exception that quarantines the whole file. Well-formed records emit structured events into two lanes: valid entries flow toward reconciliation, and rejected records flow into a dead-letter queue carrying enough metadata to replay them during a dispute.
For the fixed-width, semantically flat legacy layout described here, positional slicing is the whole game. The moment an institution migrates the same flows to structured XML, the parsing model inverts from byte offsets to namespace-aware element resolution — the tradeoffs are compared in ISO 20022 vs Legacy Formats.
Phase-by-Phase Implementation
The parser is built as a chain of generators so memory stays flat regardless of file size, and every monetary value is handled with decimal.Decimal — never float — so cent arithmetic reconciles exactly against the control totals.
1. Stream fixed 94-byte records
Read the file in binary and yield exactly 94 content bytes per record, tolerating either \r\n or \n terminators. A short final chunk is a truncation exception, not an EOF.
from typing import Generator, Tuple
RECORD_LEN = 94
def stream_records(path: str) -> Generator[Tuple[int, str], None, None]:
"""Yield (byte_offset, record) for each 94-char NACHA record.
Memory is O(1): one record is resident at a time regardless of file size.
"""
offset = 0
with open(path, "rb") as fh:
while True:
chunk = fh.read(RECORD_LEN)
if not chunk:
return
if len(chunk) < RECORD_LEN:
raise ValueError(f"Truncated record at offset {offset}: {len(chunk)} bytes")
# Consume the trailing line terminator without disturbing the offset math.
terminator = fh.read(1)
if terminator == b"\r":
fh.read(1) # swallow the \n of a CRLF pair
yield offset, chunk.decode("ascii", errors="replace")
offset += RECORD_LEN
2. Classify the record and drive the state machine
The FSM rejects any record that does not match the expected next state. Repeated entry-detail records are legal, so ENTRY_DETAIL self-loops.
from enum import Enum
from typing import Optional
class RecordType(Enum):
FILE_HEADER = "1"
BATCH_HEADER = "5"
ENTRY_DETAIL = "6"
ADDENDA = "7"
BATCH_CONTROL = "8"
FILE_CONTROL = "9"
# Legal successors keyed by the current record type.
TRANSITIONS: dict[RecordType, set[RecordType]] = {
RecordType.FILE_HEADER: {RecordType.BATCH_HEADER},
RecordType.BATCH_HEADER: {RecordType.ENTRY_DETAIL},
RecordType.ENTRY_DETAIL: {RecordType.ENTRY_DETAIL, RecordType.ADDENDA, RecordType.BATCH_CONTROL},
RecordType.ADDENDA: {RecordType.ENTRY_DETAIL, RecordType.BATCH_CONTROL},
RecordType.BATCH_CONTROL: {RecordType.BATCH_HEADER, RecordType.FILE_CONTROL},
RecordType.FILE_CONTROL: set(),
}
def next_state(current: Optional[RecordType], incoming: RecordType) -> None:
"""Raise if `incoming` is not a legal successor of `current`."""
if current is None:
if incoming is not RecordType.FILE_HEADER:
raise ValueError(f"File must open with record type 1, got {incoming.value}")
return
if incoming not in TRANSITIONS[current]:
raise ValueError(f"Illegal transition {current.value} -> {incoming.value}")
3. Decode fields with a positional schema
Positions are 1-indexed in the spec, so a field at positions a–b maps to the Python slice [a - 1 : b]. Centralising the offsets removes off-by-one bugs from every call site — the same schema-driven principle applied in pydantic schema validation for payments.
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class EntryDetail:
transaction_code: str
routing_number: str # full 9 digits (positions 4-12)
account_number: str
amount: Decimal # dollars, exact
individual_name: str
trace_number: str
def parse_entry_detail(record: str) -> EntryDetail:
"""Slice a Type-6 record into typed fields. Amount is cents -> Decimal dollars."""
cents = record[29:39].strip() or "0"
return EntryDetail(
transaction_code=record[1:3],
routing_number=record[3:12],
account_number=record[12:29].strip(),
amount=(Decimal(cents) / Decimal(100)),
individual_name=record[54:76].strip(),
trace_number=record[79:94].strip(),
)
4. Validate routing checksums and transaction codes
The ABA checksum is the cheapest way to catch a corrupted or misaligned routing field before it reaches settlement.
from decimal import Decimal
ABA_WEIGHTS = (3, 7, 1, 3, 7, 1, 3, 7, 1)
DEBIT_CODES = {"27", "37", "28", "38"}
CREDIT_CODES = {"22", "32", "23", "33"}
def valid_routing(routing: str) -> bool:
"""ABA weighted-modulus check on the full 9-digit routing number."""
if len(routing) != 9 or not routing.isdigit():
return False
total = sum(int(d) * w for d, w in zip(routing, ABA_WEIGHTS))
return total % 10 == 0
def signed_amount(entry: EntryDetail) -> Decimal:
"""Positive for credits, negative for debits; unknown codes raise."""
if entry.transaction_code in CREDIT_CODES:
return entry.amount
if entry.transaction_code in DEBIT_CODES:
return -entry.amount
raise ValueError(f"Unknown transaction code {entry.transaction_code!r}")
5. Reconcile control totals
Batch Control (8) and File Control (9) exist to prove nothing was truncated in transit. The entry hash is the sum of the RDFI routing numbers (first 8 digits) across a batch, truncated to its rightmost 10 digits; debit and credit totals are independent 12-digit cent counts. Every one must match what the parser accumulated.
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass
class BatchTotals:
entry_count: int = 0
entry_hash: int = 0 # sum of 8-digit RDFI routings, mod 10**10
debit_cents: Decimal = field(default_factory=lambda: Decimal(0))
credit_cents: Decimal = field(default_factory=lambda: Decimal(0))
def add(self, entry: EntryDetail) -> None:
self.entry_count += 1
self.entry_hash = (self.entry_hash + int(entry.routing_number[:8])) % 10**10
if entry.transaction_code in CREDIT_CODES:
self.credit_cents += entry.amount
else:
self.debit_cents += entry.amount
def reconcile_batch_control(computed: BatchTotals, control: str) -> list[str]:
"""Compare accumulated totals against the Type-8 record. Returns failure codes."""
failures: list[str] = []
declared_count = int(control[4:10])
declared_hash = int(control[10:20])
declared_debit = Decimal(control[20:32]) / Decimal(100)
declared_credit = Decimal(control[32:44]) / Decimal(100)
if declared_count != computed.entry_count:
failures.append("BATCH_ENTRY_COUNT_MISMATCH")
if declared_hash != computed.entry_hash:
failures.append("BATCH_ENTRY_HASH_MISMATCH")
if declared_debit != computed.debit_cents:
failures.append("BATCH_DEBIT_TOTAL_MISMATCH")
if declared_credit != computed.credit_cents:
failures.append("BATCH_CREDIT_TOTAL_MISMATCH")
return failures
6. Route exceptions to an audit-ready dead-letter queue
Hard exceptions (sequence violations, control-total mismatches) quarantine the file; soft exceptions (a single failed entry) isolate one record and let the batch proceed. Every rejection carries the file hash, byte offset, failure code, and timestamp so it can be replayed during a Reg E dispute. For multi-million-record files, hand the accepted-entry stream to the vectorised high-volume pandas parsing strategies rather than looping in pure Python.
import hashlib
import logging
from datetime import datetime, timezone
from typing import Iterator
log = logging.getLogger("nacha")
def audit_event(file_hash: str, offset: int, code: str, severity: str) -> dict[str, str]:
"""Immutable metadata written to the DLQ before any downstream processing."""
return {
"file_hash": file_hash,
"record_offset": str(offset),
"failure_code": code,
"severity": severity, # "hard" quarantines the file; "soft" isolates the entry
"logged_at": datetime.now(timezone.utc).isoformat(),
}
def file_sha256(path: str) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for block in iter(lambda: fh.read(65536), b""):
h.update(block)
return h.hexdigest()
Programmatic validation of the batch header specifically — Service Class Code alignment, SEC code whitelist, and company-identifier checks — is covered in depth in How to Validate NACHA Batch Headers Programmatically.
Edge Cases & Known Failure Modes
| Failure scenario | Root cause | Mitigation |
|---|---|---|
| Off-by-one field extraction | 1-indexed spec positions sliced as 0-indexed | Central positional schema; unit-test each slice against a known record |
| Entry hash mismatch on a valid file | Summing the full 9-digit routing instead of the first 8; forgetting the mod 10^10 truncation | Hash only positions 4–11; apply % 10**10 after each add |
| Amount parsed as float | int(field) / 100 introduces binary rounding drift |
Use Decimal(cents) / Decimal(100) end-to-end |
| Unbalanced batch at File Control | A 9 record arrives while a batch is still open |
FSM flags 9 as illegal unless the last record was 8; quarantine the file |
| Blocking factor padding misread | Files are padded with 9-filled records to a 10-record block; these are not real File Controls |
Stop at the first genuine 9 File Control; treat trailing all-9 lines as filler |
| Non-ASCII bytes in addenda | Payer-supplied remittance contains UTF-8 or Latin-1 | Decode with errors="replace" and route the entry to soft-exception review |
| Truncated final record | Transfer cut short; last chunk < 94 bytes | Raise on short read; never silently pad to length |
| CRLF vs LF drift | Windows-origin files use \r\n, mainframe files use \n or fixed blocking |
Consume the terminator explicitly after each 94-byte read |
Compliance & Auditability
The record model exists to serve a regulatory contract, not just a data contract. The field layouts, SEC codes, and control-total arithmetic are defined by the NACHA Operating Rules & Guidelines (Article Two, Batch and Entry formatting; Appendix Three, ACH Record Format Specifications). Return handling — the R01 insufficient funds, R02 account closed, R03 no account, R04 invalid account number codes emitted for soft-exception entries — follows the return-reason framework in Article Three and Appendix Four.
Because most ACH entries are consumer debits and credits, the parser sits inside the compliance perimeter of Regulation E (12 CFR 1005). Section 1005.11 sets the error-resolution timeline, which is why every rejected record must persist an immutable audit trail (file hash, offset, failure code, timestamp): the institution must be able to reconstruct exactly what it received and when. Aggregate settlement totals reconcile back to the Federal Reserve under its ACH Operating Circular, so a File Control total that disagrees with the accumulated entries is a hard stop, never a warning.
Testing & Verification
Validate the parser against synthetic records whose control totals you compute by hand, and assert both the happy path and the FSM guardrails.
import pytest
from decimal import Decimal
def make_entry(routing: str, cents: str, tx: str = "22") -> str:
"""Build a 94-char Type-6 record for a routing/amount pair."""
rec = (
"6" + tx + routing[:8] + routing[8]
+ "ACCT1".ljust(17) + cents.rjust(10, "0")
+ "".ljust(15) + "JANE DOE".ljust(22) + " " + "0"
+ "091000010000001"
)
return rec.ljust(94)
def test_amount_is_exact_decimal():
entry = parse_entry_detail(make_entry("021000021", "0000012345"))
assert entry.amount == Decimal("123.45")
assert isinstance(entry.amount, Decimal)
def test_valid_routing_checksum():
assert valid_routing("021000021") is True # JPMorgan Chase, NY
assert valid_routing("021000020") is False # corrupted check digit
def test_illegal_transition_is_rejected():
with pytest.raises(ValueError, match="Illegal transition"):
next_state(RecordType.BATCH_HEADER, RecordType.FILE_CONTROL)
def test_entry_hash_truncates_to_ten_digits():
totals = BatchTotals()
for _ in range(3):
totals.add(parse_entry_detail(make_entry("099999999", "0000000100")))
assert totals.entry_hash == (9999999 * 3) % 10**10
assert totals.credit_cents == Decimal("3.00")
A structured fixture keeps regression coverage honest across the record types:
{
"file": "sample-ppd.ach",
"expected": {
"batch_count": 1,
"entry_count": 3,
"entry_hash": 29999997,
"total_credit": "3.00",
"total_debit": "0.00",
"sequence": ["1", "5", "6", "6", "6", "8", "9"]
}
}
Frequently Asked Questions
Why is the NACHA amount field an integer instead of a decimal?
Positions 30–39 hold a 10-digit, zero-padded count of cents with an implied two-place decimal — there is no literal decimal point in the file. This keeps the record fixed-width and avoids locale-dependent decimal separators. Parse it as an integer of cents and convert with Decimal(cents) / Decimal(100); never divide by 100.0, because binary floating point cannot represent most cent values exactly and the drift will surface as a one-cent control-total mismatch across a large batch.
My entry hash never matches the Batch Control record. What am I summing wrong?
Two mistakes account for almost every entry-hash break. First, the hash sums only the first 8 digits of each RDFI routing number (positions 4–11), not the full 9-digit number that includes the check digit. Second, the running total is truncated to its rightmost 10 digits — apply % 10**10 after each addition, and if the declared hash in the control record is shorter, compare on the same truncated width.
How do I tell a real File Control record from block padding?
NACHA files are written in blocks of 10 records; the last block is padded with records that are entirely the digit 9 to fill the block. Those filler lines are not File Control records. Parse the first genuine 9 record as File Control (it carries real batch and entry counts), then treat any subsequent all-9 lines as padding and stop. If your reader tries to field-decode the padding, it will report absurd counts and totals.
What is the difference between a hard exception and a soft exception here?
A hard exception means the file itself cannot be trusted — an out-of-sequence record type, an unbalanced batch, or a control-total mismatch — so the entire file is quarantined and nothing settles. A soft exception is a single entry that fails validation (bad routing checksum, unknown transaction code, non-ASCII remittance) while the rest of the batch is structurally sound; that one record is isolated, tagged with a return code such as R03, and the remaining entries proceed. Keeping the two lanes separate is what lets you meet settlement cutoffs without ignoring bad data.
Can I parse a multi-gigabyte ACH file without running out of memory?
Yes — that is the entire point of the generator chain. Reading exactly 94 bytes per record and yielding one typed object at a time keeps resident memory at regardless of file size, versus the blow-up of readlines() or loading the file into a list. For heavy post-parse aggregation, stream the accepted entries into a columnar frame rather than a Python loop.
Does addenda ordering matter for reconciliation?
Yes. A Type-7 addenda record is bound to the Type-6 entry immediately preceding it, and the entry's Addenda Record Indicator (position 79) must be 1 when one follows. If addenda appear without a preceding entry, or the indicator disagrees with the actual records, the association is ambiguous and the remittance data cannot be reliably attached — treat it as a soft exception and route the affected entry to manual review.
Related on this hub
- Core Architecture & Payment File Standards — the parent reference architecture for ingesting, validating, and auditing payment files.
- How to Validate NACHA Batch Headers Programmatically — Service Class, SEC code, and company-identifier checks on the Type-5 record.
- Secure File Transfer Protocols for Banks — the encrypted transport and integrity layer that runs before the parser.
- ISO 20022 vs Legacy Formats — how positional NACHA parsing compares to namespace-aware XML.
- Fixed-Width File Decoding — the generic byte-level slicing discipline this record grammar builds on.