COBOL COMP-3 migration without losing a cent
COBOL COMP-3 migration fails when scale, signs, rounding, or invalid bytes change. Map the data contract and prove every monetary result.

A monetary rewrite has one acceptance threshold: the same valid input must produce the same amount, sign, status, and stored representation wherever that representation remains part of an interface. A one-cent difference is not a cosmetic defect. Across a million records it can change a ledger total, an exception queue, an interest tier, or the file a downstream program accepts.
The dangerous assumption is that a COBOL field maps to a modern language type. It does not. A field gets its meaning from the PICTURE, USAGE, compiler options, arithmetic statements, receiving fields, file layout, and sometimes decades of tolerated bad data. PIC S9(7)V99 COMP-3 describes a signed integer coefficient with an implied scale of two and a packed storage contract. Treating it as a generic number throws away at least half that information.
I have seen teams spend more time arguing about decimal versus double than tracing the MOVE that actually drops fractions. That argument starts too late. First recover the numeric contract. Then choose a target representation that can enforce it, and run both systems against the same traffic until differences have explanations.
The PICTURE clause is part of the value
The PICTURE clause tells you the number of digits, the scale, the sign capability, and sometimes scaling positions that do not occupy storage. None of those details belong only to formatting.
Take these declarations:
01 INVOICE-AMOUNT PIC S9(7)V99 COMP-3.
01 TAX-RATE PIC S9(3)V9(4) COMP-3.
01 WHOLE-DOLLARS PIC S9(9) COMP-3.
01 SMALL-RATIO PIC SV9(6) COMP-3.
V is an assumed decimal point. No decimal-point byte exists in memory or on disk. INVOICE-AMOUNT stores nine decimal digits plus a sign, and its coefficient 123456789 means 1234567.89. TAX-RATE uses scale four. WHOLE-DOLLARS has scale zero. SMALL-RATIO has no integer digit positions, so coefficient 123456 means 0.123456.
A migration inventory should therefore record at least (signed, precision, scale, usage, byte length) for every elementary numeric item. Preserve the original declaration and its containing record layout beside that normalized form. Copybooks use REDEFINES, OCCURS, condition names, and group moves, so a field cannot always be interpreted independently of neighboring bytes.
The symbol P needs separate treatment. It describes assumed scaling positions that are not stored. IBM's Enterprise COBOL documentation gives examples such as PPP999, whose stored digits represent values from zero through .000999, and S999PPP, whose nonzero values advance in thousands. A mapper that counts only stored digits will miss the arithmetic scale. Do not guess a database DECIMAL(p,s) from byte length.
Produce a machine-readable catalog rather than a spreadsheet that drifts away from the code. A useful entry looks like this:
{
"qualifiedName": "CLAIM-REC.PAID-AMOUNT",
"picture": "S9(7)V99",
"usage": "COMP-3",
"bytes": 5,
"precision": 9,
"scale": 2,
"signed": true,
"storage": "packed-decimal"
}
That artifact is the beginning of the rewrite contract. It also catches a common parsing error: nine digits in packed decimal need five bytes because the final half-byte holds the sign.
Resolve aliases before assigning ownership. A REDEFINES branch may treat the same five bytes as an amount in one transaction type and as filler or a date in another. The discriminator that selects the branch is part of the numeric contract. If the new ingestion layer eagerly decodes every possible branch, it can reject valid records because bytes that are numeric under one layout are text under another. Record the controlling condition, not just the overlapping offsets.
Group operations need attention for the opposite reason. MOVE OLD-GROUP TO NEW-GROUP copies bytes without applying elementary numeric conversion rules. Replacing it with field-by-field object mapping can normalize signs, change padding, or decode a field the source never inspected. Classify each use as a byte operation or a numeric operation before deciding that a typed object is equivalent.
COMP-3 bytes need a decoder, not a cast
Packed decimal stores two decimal digits per byte, except that the low nibble of the last byte carries the sign. The target runtime must validate and decode those nibbles explicitly at every external boundary.
For PIC S9(5)V99 COMP-3, the value -12345.67 has coefficient -1234567 and can appear as:
12 34 56 7D
Read the nibbles as 1 2 3 4 5 6 7 D. The first seven are digits. The final D is negative. A conventional positive value ends in C; unsigned packed data commonly ends in F. Real systems may contain other sign codes depending on compiler settings and the path that created the data. That is exactly why the decoder needs a declared policy rather than a permissive hexadecimal conversion.
The byte length for n stored decimal digits is floor(n / 2) + 1. When the digit count is even, the first nibble is padding. It still matters during validation. IBM documents that NUMCHECK(PAC) checks packed digits and signs when fields are used as senders, and for an even digit count it also checks the unused bits. Your new decoder should decide whether malformed padding rejects the record, enters a quarantine path, or matches an explicitly documented legacy tolerance.
This pseudocode makes the boundary visible:
decodePacked(bytes, precision, scale, signed):
nibbles = splitEachByte(bytes)
signNibble = nibbles.removeLast()
if precision is even:
require nibbles.removeFirst() == 0
require count(nibbles) == precision
require every nibble is between 0 and 9
sign = decodeSign(signNibble, signed, configuredSignPolicy)
coefficient = sign * decimalDigitsToInteger(nibbles)
return FixedDecimal(coefficient, scale)
Keep the coefficient as an integer and the scale as metadata. This representation makes 123.40 distinct from a scale-free floating value even if a display layer later prints 123.4. It also lets an encoder reproduce fixed-width records exactly.
Write the encoder independently, then test encode(decode(bytes)) for every valid canonical input. Also test noncanonical inputs that policy accepts. Numeric equivalence may allow the encoder to canonicalize a positive F sign to C, but byte parity will fail if an external consumer expects the original form. Where exact round trips matter, retain the original sign code or the complete raw field beside the decoded number.
Do not let the decoder return zero after an error. Some conversion libraries do that when a parse status goes unchecked, turning malformed money into a legitimate amount. Return a tagged result that forces the caller to handle valid, invalid, and deferred states. The type system should make accidental arithmetic on undecoded bytes difficult.
Negative zero deserves a test. Packed or zoned input can carry a negative sign with zero digits. Most business arithmetic treats -0.00 and 0.00 as numerically equal, but a byte-for-byte outbound file, audit feed, or sign-sensitive branch may not. Decide whether decoding normalizes it, retains a sign flag, or preserves the original bytes for round-trip output. Silence is not a policy.
Signed overpunch is a different storage contract
Signed overpunch belongs to zoned decimal or numeric DISPLAY data, not COMP-3, even though both formats squeeze a sign into a digit position. Confusing the two corrupts values while still producing plausible-looking characters.
In EBCDIC zoned decimal, each digit occupies a byte. For a trailing overpunch, the high nibble of the last digit byte carries the sign while the low nibble carries the final digit. A positive 123 may end with a byte whose zone nibble indicates positive, while -123 uses a negative zone nibble. When converted to characters, those byte patterns may appear as letters or braces under familiar overpunch tables. That visual form is an encoding convention, not the numeric value itself.
The field declaration and file encoding have to travel together. An ASCII parser that sees 12L cannot safely infer a negative three without knowing which overpunch table produced it. An EBCDIC-to-Unicode conversion performed before numeric decoding can also destroy the original zone bits or map them in a way a generic decimal parser rejects.
Decode in this order:
- Slice the record by its byte layout, before character conversion changes offsets.
- Apply the declared EBCDIC code page to ordinary text fields, but send numeric DISPLAY bytes to a zoned-decimal decoder.
- Validate every digit zone and the allowed sign set.
- Return the same coefficient-and-scale representation used for packed decimal.
- Retain raw bytes with rejected records so an operator can identify the actual producer.
SIGN IS LEADING, SIGN IS TRAILING, and SIGN IS SEPARATE alter that contract. A separate sign consumes its own character position; an overpunched sign does not. Copybook parsers that flatten all signed DISPLAY items into one rule will shift record boundaries or drop the sign.
There is one useful unification: packed decimal and zoned decimal can share the domain representation after decoding. They must not share the boundary decoder. Separate decoders keep storage rules out of business calculations and give malformed data a precise error such as invalid packed digit at byte 3 rather than number format error.
Exact decimal types are necessary but not sufficient
Use integer coefficients, fixed-point decimal types, or database numerics for money. Never route a COBOL decimal through binary floating point, including a temporary JSON number or spreadsheet cell, because many decimal fractions have no exact binary representation.
The target mapping should follow observed operations and range. A field such as S9(7)V99 COMP-3 can use a signed 64-bit coefficient with scale two only after intermediate products are proved to fit. Values approaching 31 decimal digits usually need an arbitrary-precision coefficient. Database money belongs in DECIMAL(p,s) or NUMERIC(p,s) after tests establish the database's rounding and overflow behavior. Wire and JSON boundaries should carry decimal strings with explicit scale rules so consumers do not coerce them to floating point.
Go has no built-in arbitrary-precision fixed decimal. A team can store cents in int64 where the complete calculation proves that range is enough, or wrap math/big.Int as a coefficient with controlled scale. Rust can use checked integer arithmetic or a decimal library whose precision and rounding modes have been audited. TypeScript's number is binary floating point; use strings, scaled bigint, or a tested decimal implementation for monetary work. PostgreSQL numeric is exact for decimal values, but the application still controls when scale reduction happens.
Do the range proof with intermediates, not only stored fields. A nine-digit amount multiplied by a seven-digit rate can need far more than nine digits before division or rescaling. COBOL may hold that intermediate at a precision chosen by its arithmetic rules and compiler options. A target int64 can represent every input field and still overflow their product.
Also distinguish storage scale from business unit. PIC S9(7)V99 often means currency to cents, but the declaration does not say which currency, whether fractions of a cent are permitted during calculation, or whether the value is tax, a rate, or an amount. Put those meanings in domain types when the program reveals them. Money, Rate, and Quantity should not multiply or add merely because all three use a decimal coefficient.
Do not use a database schema as the first and only specification. A column widened over the years may accept values the COBOL field cannot store. A narrower column may reveal that an interface already rounded before persistence. Map source declarations, statements, record layouts, and database constraints as one numeric flow.
Define arithmetic APIs around the domain rather than exposing a general decimal object everywhere. An amount can add another amount in the same unit. A rate can multiply an amount and produce a higher-scale intermediate. Allocation needs a documented remainder rule because splitting 10.00 three ways cannot give every recipient the same cents. These restrictions reveal business rules that a permissive decimal library would let developers bypass.
Serialization also needs a contract. Decide whether scale two always emits 12.30, whether plus signs are allowed, whether exponent notation is forbidden, and what maximum digit count a consumer accepts. A JSON string avoids binary conversion inside your service, but it does not stop a browser, message mapper, or analytics loader from turning it into a floating number later. Contract tests should cross the actual consumer boundary.
Rounding happens at receiving boundaries
COBOL rounding is tied to arithmetic statements and receiving fields, so matching the final type without matching each scale transition gives different cents. The presence or absence of the ROUNDED phrase is observable behavior.
Consider a rate calculation:
01 WS-BASE PIC S9(7)V99 COMP-3.
01 WS-RATE PIC S9(2)V9(5) COMP-3.
01 WS-FEE PIC S9(7)V99 COMP-3.
COMPUTE WS-FEE ROUNDED = WS-BASE * WS-RATE
With WS-BASE = 100.00 and WS-RATE = 0.01255, the exact product is 1.2550000. Moving the result to scale two with ordinary nearest rounding produces 1.26. Without ROUNDED, discarded positions are truncated, so the stored result is 1.25. A modern rewrite that globally chooses bankers' rounding can instead produce 1.26 for some ties and 1.24 for others where COBOL's chosen mode would move away from zero. You must derive the actual rule from the compiler, dialect, statement, and tests.
Do not scatter round(2) calls through translated business code. Model rescaling as an operation with named semantics:
rescale(value, targetScale, mode)
modes: truncate, nearestAway, nearestEven, floor, ceiling
Then annotate every scale-losing edge in the recovered flow. Edges include arithmetic receivers, MOVE statements, calls with narrower parameters, database assignments, report fields, and outbound records. A MOVE can be the place cents disappear even when the calculation itself retained four fractional positions.
Sign matters during truncation. Truncating -1.259 toward zero yields -1.25; taking the mathematical floor yields -1.26. Language operators disagree about negative division and remainder, so test the implementation instead of assuming an integer shortcut behaves like COBOL.
Size errors are another part of the result. ON SIZE ERROR can branch when the receiving field cannot hold the value. Other paths may truncate high-order digits or rely on compiler behavior that the rewrite should reject. The parity record must include whether the source took the size-error branch, not merely the numeric result it stored.
One arithmetic statement can have multiple receivers with different PICTURE clauses. COBOL applies the result to each receiver according to that receiver's capacity and rounding phrase. A refactor that calculates once into the narrowest target and copies it to the others loses information earlier than the source. Calculate at the recovered intermediate precision, then rescale independently for each receiving edge.
Currency rules can demand something other than two decimal places. Cash rounding, currencies with no minor unit, and calculations that retain fractions of a cent all exist, but the copybook alone does not select a policy. Recover the rule from statements, tables, and outputs. Do not attach a universal Money.round() method and hope every call wants the same answer.
Intermediate precision changes the answer
Expression order, compiler arithmetic options, and temporary field definitions can alter a final cent even when all source and target fields use exact decimals. Exact arithmetic does not mean unlimited arithmetic.
Compare these shapes:
A = roundToCents(BASE * RATE)
B = roundToCents(roundToScale4(BASE * RATE_PART_1) +
roundToScale4(BASE * RATE_PART_2))
They are algebraically related, but they need not be numerically equal. The source may round each component into a work field before adding. A rewrite that combines the expression and rounds once has changed the program. Optimizing away COBOL work fields before parity is established is a reliable way to manufacture small differences that are painful to trace.
IBM's Enterprise COBOL documentation distinguishes ARITH(COMPAT) and ARITH(EXTEND). The former limits decimal operands to 18 digits, while the latter permits up to 31 and changes fixed-point intermediate capacity. IBM also warns that NUMVAL can involve approximate values and that the arithmetic option affects its results. That is a sharp reminder: a field that looks decimal at rest may pass through floating or differently sized intermediates during conversion.
Inventory compiler and runtime settings for every load module in scope. The same source compiled under different options is not automatically the same executable contract. Capture ARITH, NUMPROC, TRUNC, dialect, compiler version, and any relevant runtime settings. If build records are missing, create probe programs and run boundary inputs on the production-compatible compiler.
A useful probe matrix includes positive and negative ties, maximum coefficients, zero with each accepted sign, products that require one more intermediate digit, and division with a repeating decimal result. Store input bytes, DISPLAY output, result bytes, return codes, and branch markers. These tiny programs settle arguments faster than another meeting about what COBOL "normally" does.
Preserve evaluation order through the first correct version. Once the parity harness stays clean, you can simplify expressions one change at a time. Each simplification should prove that it preserves outputs across recorded traffic and generated boundaries.
Invalid numeric data is part of the migration
Production files often contain bytes that violate their copybooks, and the old program may tolerate them until a particular operation forces validation. A rewrite that silently cleans every value can be as wrong as one that crashes on the first dirty record.
IBM states that the compiler generally assumes data matches its PICTURE and USAGE. NUMCHECK(ZON,PAC) can add numeric checks when zoned or packed fields act as senders. Valid sign sets can depend on NUMPROC and installation choices. That means two programs can read the same record but expose the defect at different points because one compares the field numerically and another moves the containing group as bytes.
Walk one failure through. An inbound packed field contains 12 34 5A: valid digit nibbles, but a sign code that the active policy does not accept. The nightly program copies the enclosing group to an archive and succeeds because that operation never treats the field as numeric. A month-end total later uses the field as a sender, triggers a data exception or numeric check, and sends the record to an operator queue. A rewrite that decodes every field eagerly rejects it during ingestion. A rewrite that accepts every A-to-F sign may total it. Both changed the operational behavior.
The right response is a field-level compatibility policy backed by evidence:
- Strict fields reject any invalid digit, padding, or sign immediately.
- Deferred fields retain raw bytes and decode at the same semantic boundary as the source.
- Known tolerated encodings get explicit decoder cases and named fixtures.
- Rejected records carry record identity, field name, byte offset, and hexadecimal input.
Do not make permissiveness the default. First run the source with available diagnostics in a representative environment, sample real files, and locate every producer. Dirty data is often evidence of an undocumented interface, not a quirky mainframe habit.
This distinction also changes rollout planning. A numeric mismatch on valid data is a rewrite defect. A newly detected invalid record may be a source-data defect, a producer defect, or a deliberate compatibility difference. The team needs separate counters and owners for those categories or the parity dashboard turns into an argument over one red total.
A parity harness must compare more than totals
A parity harness should replay identical transactions through the source and target, then compare field-level outputs, branches, errors, and serialized bytes before it compares batch totals. Aggregate equality can hide pairs of opposite errors.
Recorded production traffic gives you realistic combinations, but it rarely covers numeric boundaries. Add generated cases around each recovered contract:
- zero, negative zero, minimum, maximum, and one unit beyond the valid range;
- every half-way value at each scale-losing operation, on both sides of zero;
- each accepted and rejected packed or overpunched sign code;
- invalid digits, padding, short records, and encoding errors;
- values that overflow only after multiplication or scale alignment.
For every case, capture a comparison envelope such as:
{
"case": "fee-negative-half-cent",
"inputHex": "00001000C000125C",
"source": {
"coefficient": "-126",
"scale": 2,
"status": "OK",
"outputHex": "0000126D"
},
"target": {
"coefficient": "-126",
"scale": 2,
"status": "OK",
"outputHex": "0000126D"
}
}
The exact envelope will vary, but strings are deliberate for coefficients that can exceed a consumer's safe integer range. Hexadecimal fields make sign and padding differences visible. Status includes source branches such as ON SIZE ERROR, not only process exit codes.
When a million-record batch differs by a cent, bisect by record range, then by transaction, field, and arithmetic edge. Log the unscaled operands, their scales, the operation, the intermediate precision, and the rescale mode. A trace that prints only expected 19.42, got 19.43 leaves the hard work for a person at the worst possible moment.
Run three comparison levels. Numeric parity checks coefficients after aligning declared scales. Behavioral parity checks decisions, exceptions, and downstream records. Byte parity checks fixed-format interfaces that must remain identical. Do not demand byte parity from a newly designed API where formatting can change safely, and do not settle for numeric parity on a regulatory extract whose consumer reads columns by byte offset.
Treat comparison exclusions as code. If timestamps, sequence numbers, or intentionally redesigned formatting differ, normalize only those named fields and review the rule like production logic. A broad "ignore whitespace" option can erase a meaningful sign position or column shift. Every exclusion needs an owner and an expiry condition.
Release gates should report mismatch classes, not a blended pass percentage. Zero unexplained monetary differences is a sensible gate even when approved interface changes remain. Keep the source replayable until each mismatch has a fixture, a decision, and a regression test; otherwise the same cent can return after an unrelated optimization.
CodeHero uses recorded production traffic in a parity harness for this reason: translation that compiles is not evidence that decimal behavior survived. For large estates, automate the field catalog and trace generation so reviewers can spend time on mismatches instead of transcribing copybooks.
The migration contract should be reviewable
The finished numeric contract should let an engineer trace any target amount back to source bytes and forward through every rounding boundary. If the contract lives only in the head of the last COBOL maintainer, the rewrite is not ready.
Require these items before cutting over a monetary path:
- Every source numeric field has a qualified name, PICTURE, USAGE, byte range, encoding, precision, scale, and sign policy.
- Every target type has a written range proof that includes intermediate values.
- Every loss of scale names its rounding or truncation mode and the source statement that established it.
- Invalid data behavior has fixtures for accepted, rejected, deferred, and negative-zero cases.
- The parity suite compares numeric, behavioral, and byte results where each one matters.
Keep this contract beside the replacement code and generate as much of it as possible. Reviewers should be able to challenge a mapping such as S9(11)V9(6) -> int64 with the largest operands and see the answer, not accept a note that says "fits."
After cutover, keep boundary metrics that distinguish decode failures, overflows, scale reductions, and parity misses. Do not emit account values or full records into general logs. Field identifiers, operation identifiers, safe record references, and redacted coefficients are usually enough to locate a fault without creating another data problem.
The standard for money is deliberately severe. Every cent needs a provenance: source digits, sign interpretation, scale, intermediate operations, and final rescale rule. Once the rewrite can show that chain for a failure and prove it across the corpus, the old representation can disappear without taking its behavior with it.
FAQ
What is COMP-3 in COBOL?
COMP-3 is packed decimal storage. It places two decimal digits in most bytes and reserves the last half-byte for a sign, while the PICTURE clause supplies precision and scale.
How many bytes does a COMP-3 field use?
For n decimal digits, use floor(n / 2) + 1 bytes. The extra half-byte holds the sign, and an even digit count leaves a leading padding nibble that should still be validated.
Does the V in a COBOL PICTURE occupy a byte?
No. V marks an implied decimal point and occupies no storage, so S9(5)V99 stores seven digits plus a sign while carrying scale two.
Can COBOL money be migrated to double or float?
No, not safely. Binary floating point cannot exactly represent many decimal fractions, and temporary conversion through it can change rounding even if the value later returns to a decimal type.
Is signed overpunch the same as COMP-3?
No. Overpunch embeds the sign in the zone bits of a DISPLAY digit byte, while COMP-3 stores decimal digits in nibbles and uses the final nibble for the sign.
Which sign nibble means negative in packed decimal?
D is the conventional negative sign nibble, while C commonly means positive and F commonly appears for unsigned data. Treat the accepted set as a compiler and interface policy because production data can contain other codes.
Does COBOL round monetary results automatically?
Not as a universal rule. Rounding depends on the statement, the receiving field, and whether ROUNDED appears; scale loss without it commonly truncates instead.
Why can exact decimal arithmetic still differ from COBOL?
Exact types still have finite precision, evaluation order, and rescaling rules. Changing a temporary field, combining expressions, or rounding once instead of after each component can move the final cent.
How should negative zero be migrated?
Choose and document whether to normalize it, retain a sign flag, or preserve the original bytes for round trips. Numeric equality alone does not settle a sign-sensitive branch or fixed-format output.
What proves a COBOL monetary rewrite is correct?
Replay the same inputs through both systems and compare coefficients, scales, branches, errors, and required output bytes. Add generated boundary cases because recorded traffic rarely contains every tie, overflow, sign code, or malformed field.