COBOL anti-pattern
COMP-3 packed decimal arithmetic
Fixed-point decimal arithmetic with silent high-order truncation and truncate-or-round rules set per statement, which floating point and careless BigDecimal code do not reproduce.
- FAMILY
- COBOL
- CUTOVER RISK
- Critical
- Changes runtime behavior in ways a line-by-line reading misses. Resolve before any cutover plan.
- MODERN MAPPING
- BigDecimal with explicit scale and rounding mode
- GENERATED ARTIFACT
- openapi.components.yaml
Before and after
legacy source → modern mapping
Legacy: exhibits the pattern
1 01 WS-BALANCE PIC S9(9)V99 COMP-3.2 01 WS-RATE PIC SV9(5) COMP-3.3 01 WS-DAILY-INT PIC S9(7)V99 COMP-3.4 01 WS-MONTH-INT PIC S9(7)V99 COMP-3.5 * 6 + 3 + 5 + 5 BYTES6 ...7 COMPUTE WS-DAILY-INT ROUNDED =8 WS-BALANCE * WS-RATE / 3659 ADD WS-DAILY-INT TO WS-MONTH-INT10 COMPUTE WS-FEE = WS-MONTH-INT * 0.01511 * NO ROUNDED: FRACTION TRUNCATED
Modern: BigDecimal with explicit scale and rounding mode
1static final MathContext MC = MathContext.DECIMAL128;23BigDecimal dailyInterest(BigDecimal bal, BigDecimal rate) {4 // COMPUTE ... ROUNDED: half away from zero5 return bal.multiply(rate, MC)6 .divide(BigDecimal.valueOf(365), MC)7 .setScale(2, RoundingMode.HALF_UP);8}910BigDecimal fee(BigDecimal monthInterest) {11 // COMPUTE without ROUNDED: truncate12 return monthInterest13 .multiply(new BigDecimal("0.015"))14 .setScale(2, RoundingMode.DOWN);15}
Why it is dangerous in modernization
COMP-3 stores two decimal digits per byte with a sign nibble, and COBOL arithmetic on it is exact decimal arithmetic with the precision of each receiving field. A COMPUTE without ROUNDED truncates extra fractional digits; with ROUNDED it rounds half away from zero. If the result has more integer digits than the receiving PIC allows and there is no ON SIZE ERROR, the high-order digits are dropped without any error. Intermediate precision depends on the compiler and its ARITH option. Migrations that move these values into double lose cents on ordinary interest and fee calculations, and migrations into BigDecimal often use the wrong rounding mode or skip the truncation to the target scale, so totals drift by a cent on a small fraction of accounts. In regulated calculations, a one-cent drift on a fraction of accounts is still a defect someone has to explain.
How PYD ENTERPRISES detects it
Every numeric field is typed with its digits, scale, sign and usage, and every arithmetic statement is lowered to an expression tree that records the receiving field's precision, the presence of ROUNDED and the presence of ON SIZE ERROR. The engine then computes the maximum magnitude each expression can reach from the declared pictures of its operands and flags statements where the result can exceed the receiver's integer digits with no size error handler. Those are the silent truncation points. Each statement exports with its exact rounding rule, so generated code and generated tests use the same one.
Maps to: BigDecimal with explicit scale and rounding mode
Map each receiving field to a decimal type with a fixed scale: BigDecimal in Java, decimal in C#, a fixed-point decimal library in Go or TypeScript. ROUNDED maps to HALF_UP (round half away from zero), and its absence maps to DOWN (truncate toward zero). Where the legacy code can truncate high-order digits silently, the new code should either reproduce that on purpose or reject the value, and the choice belongs in the reviewed specification.
Generated specification fragment
1components:2 schemas:3 Money9v2:4 description: PIC S9(9)V99 COMP-3 (6 bytes)5 type: string6 pattern: '^-?\d{1,9}\.\d{2}$'7 x-pyd-scale: 28 DailyInterest:9 description: PIC S9(7)V99 COMP-3 (5 bytes)10 type: string11 pattern: '^-?\d{1,7}\.\d{2}$'12 x-pyd-rounding: HALF_UP13 x-pyd-source: INTACCR.cbl:7-8
Every generated element links back to the source file and line range shown in the legacy pane. Review and sign off before building against it.
Sizes and the packing rule
A packed field holds one digit per nibble plus a sign nibble, so its length in bytes is (digits + 1) / 2, rounded up. S9(9)V99 has 11 digits: 12 nibbles, 6 bytes. SV9(5) has 5 digits: 6 nibbles, 3 bytes. S9(7)V99 has 9 digits: 10 nibbles, 5 bytes. The sign nibble is C for positive, D for negative and F for unsigned. Even digit counts waste the high-order nibble, which is why experienced mainframe programmers declare odd digit counts.
The implied decimal point (V) is not stored. Scale lives only in the copybook, so a converter that reads the bytes without the picture cannot know whether X'12345C' is 12345, 123.45 or 1.2345.
Why decimal strings in the API contract
JSON numbers are usually parsed into binary floating point by clients, which reintroduces exactly the error the migration is meant to avoid. PYD ENTERPRISES emits money and rate fields as strings with a pattern constrained to the legacy picture, plus an extension recording scale and rounding. Teams that prefer JSON numbers can switch the export, and the generated JSON Schema then carries multipleOf: 0.01 and min and max bounds derived from the picture.
Silent high-order truncation
If WS-MONTH-INT accumulates past 9,999,999.99, the ADD in the example keeps only the low-order seven integer digits, with no error, because there is no ON SIZE ERROR. That may never happen for retail balances and may happen weekly for corporate ones. The engine reports the statement, the maximum reachable magnitude, and the inputs that trigger it, and generates a boundary fixture at the limit and one past it.
Related patterns
Usually found in the same codebase.
Find every COMP-3 packed decimal arithmetic in your codebase.
Run PYD ENTERPRISES in your private VPC and get each occurrence with file, line range, modern mapping and equivalence fixtures, ready for your architects to review.