COBOL input validation: class tests, INSPECT and TEST-NUMVAL
A GnuCOBOL edit program that checks every field of a fixed-width feed with CLASS, INSPECT, TEST-NUMVAL and TEST-DATE-YYYYMMDD, and logs every error with a code.
An edit program stands between data from another system and everything downstream of it. It reads what was sent, decides record by record what may go on to the next step, and writes a list a person can fix the rest from. The bank-statement job earlier in this part did a small version of that: it stopped at the first fault in each record, and its own limitations section admits that it accepts 20261399 as a date. This lesson builds the full version for an 80-byte payment feed. The program checks every field of every record that reads cleanly, writes every error it finds with a numbered code, passes the clean records on in a layout the next program can do arithmetic on, and ends with totals that account for every record read.
What was run and what was not. cobc was not installed on the machine this lesson was first prepared on, so we desk-checked the listing against the GnuCOBOL Programmer's Guide, which documents the July 2020 build of GnuCOBOL 3.1 RC-1, and against the source code, test suite and NEWS file of the GnuCOBOL 3.2 release, downloaded from the GNU project's server on October 8, 2026, where 3.2 is still the newest release. The expected output below comes from the Python companion, /code/cobol-input-validation-class-inspect-test-numval.py. It copies each runtime rule the program depends on from the 3.2 source, checks the copies against GnuCOBOL's own tests and IBM's documented examples wherever those exist, and only then works out what the program should write. It ran here and passed all 302 of its checks. On 29 October 2026 we compiled and ran the program itself with GnuCOBOL 3.2: its console output, errors.txt and accepted.dat matched the expected output below line for line, and it ended with return code 4.
The feed and its rules
The copybook, /code/cobol-input-validation-class-inspect-test-numval.cpy, describes an 80-byte record:
| Field | Bytes | PICTURE | Rule |
|---|---|---|---|
| In-Account | 1 to 8 | X(8) | eight digits, zero-filled |
| In-Name | 9 to 32 | X(24) | present; starts with a letter; letters, spaces, apostrophes, hyphens and periods only |
| In-Currency | 33 to 35 | X(3) | three capital letters, and one of USD, EUR or GBP |
| In-Amount | 36 to 47 | X(12) | a number NUMVAL accepts, at most two decimals, from 0.01 to 50,000.00 |
| In-Value-Date-X | 48 to 55 | X(8), redefined as 9(8) | a real date, YYYYMMDD |
| In-Type | 56 | X | P for a payment, R for a refund |
| In-Reference | 57 to 72 | X(16) | capitals, digits and hyphens, no spaces; required for a refund |
| FILLER | 73 to 80 | X(8) | unused |
Every field the sender fills in is PIC X, and the date also has a PIC 9(8) view through REDEFINES. That is on purpose. A field described as PIC 9 promises every later statement that it holds digits, and an edit program exists because nobody has checked that yet. The amount arrives as text with a decimal point in it, not as digits with an implied decimal point.
The data file, /datasets/cobol-input-validation-class-inspect-test-numval.dat, holds 24 records built so that each of the program's 19 rules fails at least once; six records are clean. Record 20 is 81 bytes long, because its name has 25 characters and pushes every later field one byte to the right. Record 21 has eight X'00' bytes, LOW-VALUES, where the account number should be.
Every field, every error
*> 04, 06 and 09 are successful reads, so AT END was not taken.
Validate-Record.
MOVE 0 TO WS-Rec-Errors
EVALUATE WS-In-Status
WHEN "00"
PERFORM Check-Account
PERFORM Check-Name
PERFORM Check-Currency
PERFORM Check-Amount
PERFORM Check-Date
PERFORM Check-Type
PERFORM Check-Reference
WHEN "04"
WHEN "06"
MOVE 1 TO WS-Err-No
MOVE 0 TO WS-Err-Len
PERFORM Add-Error
WHEN "09"
MOVE 2 TO WS-Err-No
MOVE 0 TO WS-Err-Len
PERFORM Add-Error
END-EVALUATE
IF WS-Rec-Errors = 0
PERFORM Write-Good
ELSE
ADD 1 TO WS-Rejected
END-IF.
Validate-Record performs one paragraph per field, in record order, and all seven run for every record that was read cleanly. Inside each paragraph the rules are ordered and only the first one that fails is reported, so a date that is not numeric is not also tested as a calendar date, and a currency code that is not three capitals is not also looked up in the list. One error per field and every field per record tells the sender everything that is wrong without burying a cause under its consequences. Record 4 shows the effect: its account has a letter in it, its name has a zero where an O belongs, and its type is C, so it gets three error lines.
Each check paragraph moves the field it tests into WS-Err-Value, so that Add-Error can print the field between brackets after the message, leading and trailing spaces included. The messages sit in a table built the traditional way, nineteen FILLER items with VALUE clauses redefined as an OCCURS table; REDEFINES and OCCURS each have a lesson of their own. These are the last lines of it:
05 FILLER PIC X(28) VALUE "REFERENCE HAS BAD CHARACTER".
01 Error-Table REDEFINES Error-Texts.
05 Err-Text PIC X(28) OCCURS 19 TIMES.
01 Code-Counts.
05 Code-Count PIC 9(4) OCCURS 19 TIMES.
The code itself is not stored anywhere. Add-Error prints "E" and the entry number, so a code and its message cannot drift apart. The nineteen counters beside the table, cleared by INITIALIZE when the files open, are what the summary prints.
| Code | Message | Raised when |
|---|---|---|
| E01 | RECORD LONGER THAN 80 BYTES | READ returned status 06, or 04 |
| E02 | CONTROL CHARACTER IN RECORD | READ returned status 09 |
| E03 | ACCOUNT NOT NUMERIC | not eight digits, and not digits after leading spaces either |
| E04 | ACCOUNT NOT ZERO-FILLED | leading spaces, then digits |
| E05 | NAME MISSING | the name is all spaces |
| E06 | NAME HAS A BAD CHARACTER | the first character is not a letter, or a character is outside Name-Char |
| E07 | CURRENCY NOT 3 CAPITALS | a character outside the class Capital |
| E08 | CURRENCY NOT ACCEPTED | not USD, EUR or GBP |
| E09 | AMOUNT NOT A NUMBER | TEST-NUMVAL is not zero; its position follows "at" |
| E10 | AMOUNT HAS OVER 2 DECIMALS | cutting the value to two decimals changes it |
| E11 | AMOUNT NOT 0.01 TO 50000.00 | out of range, or too big for the work field |
| E12 | DATE NOT NUMERIC | not eight digits |
| E13 | DATE YEAR NOT 1601-9999 | TEST-DATE-YYYYMMDD returned 1 |
| E14 | DATE MONTH NOT 01-12 | it returned 2 |
| E15 | DATE DAY NOT IN MONTH | it returned 3 |
| E16 | TYPE NOT P OR R | the type is neither |
| E17 | REFUND WITHOUT REFERENCE | type R with a blank reference |
| E18 | REFERENCE HAS A SPACE | a space before the end of the reference |
| E19 | REFERENCE HAS BAD CHARACTER | a character outside Ref-Char |
Classes of your own
SPECIAL-NAMES.
*> Letters as three runs, so the classes mean the same on EBCDIC
CLASS Capital IS "A" THRU "I" "J" THRU "R" "S" THRU "Z"
CLASS Letter IS "A" THRU "I" "J" THRU "R" "S" THRU "Z"
"a" THRU "i" "j" THRU "r" "s" THRU "z"
CLASS Name-Char IS "A" THRU "I" "J" THRU "R" "S" THRU "Z"
"a" THRU "i" "j" THRU "r" "s" THRU "z"
" " "'" "-" "."
CLASS Ref-Char IS "A" THRU "I" "J" THRU "R" "S" THRU "Z"
"0" THRU "9" "-".
The built-in character classes that matter here are NUMERIC, ALPHABETIC, ALPHABETIC-LOWER and ALPHABETIC-UPPER, and the alphabetic ones are looser than their names. The guide's section on class conditions says they accept spaces as well as letters, and IBM's reference describes ALPHABETIC-UPPER as "the uppercase Latin alphabetic characters A through Z and the space" (page 270). GnuCOBOL 3.2's runtime test for it, cob_is_upper in libcob/common.c, passes any byte that is a space or an upper-case letter. So the currency code US passes ALPHABETIC-UPPER. Record 9 carries exactly that code, and the program rejects it with Capital, a class of its own that leaves the space out.
The CLASS clause in SPECIAL-NAMES names a set of characters, and a test against the class is true only when every character of the item is in the set. The rule is plain in the C that cobc writes for each class: a function that walks the field byte by byte and returns false at the first byte outside the set (output_class_name_definition in cobc/codegen.c). Two things in the listing follow from it. Name-Char has to include the space, or every name shorter than 24 characters would fail on its own padding; and because it includes the space, it would also pass a name that starts with one, so Check-Name tests the first character on its own against Letter. Ref-Char leaves the space out, so the program applies it only to the part of the reference before the padding, which INSPECT measures in the next section.
Check-Name.
MOVE In-Name TO WS-Err-Value
MOVE LENGTH OF In-Name TO WS-Err-Len
EVALUATE TRUE
WHEN In-Name = SPACES
MOVE 5 TO WS-Err-No
PERFORM Add-Error
WHEN In-Name (1:1) IS NOT Letter
OR In-Name IS NOT Name-Char
MOVE 6 TO WS-Err-No
PERFORM Add-Error
END-EVALUATE.
Check-Currency.
MOVE In-Currency TO WS-Err-Value
MOVE LENGTH OF In-Currency TO WS-Err-Len
EVALUATE TRUE
WHEN In-Currency IS NOT Capital
MOVE 7 TO WS-Err-No
PERFORM Add-Error
WHEN NOT Currency-Accepted
MOVE 8 TO WS-Err-No
PERFORM Add-Error
END-EVALUATE.
The letters are written as three runs, A to I, J to R and S to Z, for a reason that has nothing to do with GnuCOBOL. THRU names every character from the first literal to the second; in IBM's words on page 129, the class "includes those characters that begin with the value of literal-4 and that end with the value of literal-5". In ASCII the 26 capitals are consecutive codes. In EBCDIC they are not: Table 81 of IBM's reference puts A to I at X'C1' to X'C9', J to R at X'D1' to X'D9' and S to Z at X'E2' to X'E9' (pages 751 to 754). On z/OS, "A" THRU "Z" therefore covers 41 code points, the 26 capitals and 15 others, among them the closing brace at X'D0' and the backslash at X'E0', and "a" THRU "z" takes in the euro sign at X'9F' and the tilde at X'A1'. Three runs give the same 26 letters in both codes and cost nothing in ASCII.
IBM also limits where a class of your own may be tested. Table 21 on page 270 allows a class-name test on alphabetic, alphanumeric and edited items, and gives an external decimal item, a PIC 9 field in USAGE DISPLAY, only NUMERIC. Every class test in this program is on a PIC X field, which both compilers accept.
INSPECT TALLYING: why a test failed
Check-Account.
MOVE In-Account TO WS-Err-Value
MOVE LENGTH OF In-Account TO WS-Err-Len
IF In-Account IS NOT NUMERIC
MOVE 0 TO WS-Lead-Spaces
INSPECT In-Account TALLYING WS-Lead-Spaces FOR LEADING SPACES
EVALUATE TRUE
WHEN WS-Lead-Spaces = 0
OR WS-Lead-Spaces = LENGTH OF In-Account
MOVE 3 TO WS-Err-No
WHEN In-Account (WS-Lead-Spaces + 1:) IS NUMERIC
MOVE 4 TO WS-Err-No
WHEN OTHER
MOVE 3 TO WS-Err-No
END-EVALUATE
PERFORM Add-Error
END-IF.
NUMERIC fails for 42178 and for 1000A219 alike, but they are different mistakes. The first is a number that was right-justified instead of zero-filled; the second has a letter in it. INSPECT TALLYING with FOR LEADING SPACES counts the spaces at the front of the field, three here, and if the bytes after them pass NUMERIC, the record gets E04 instead of E03. The reference modification In-Account (WS-Lead-Spaces + 1:) looks at those bytes only. IBM's reference requires the start of a reference modification to fall inside the field (page 75), so the EVALUATE deals with no spaces and with eight spaces first; its WHEN phrases are tried in order, and the reference modification is reached only when it is valid. An edit program could repair the first case with INSPECT REPLACING LEADING SPACES BY ZEROS. This one reports it, because an account number is a key and a repaired key is a guess about what the sender meant.
Check-Reference.
IF In-Reference NOT = SPACES
MOVE In-Reference TO WS-Err-Value
MOVE LENGTH OF In-Reference TO WS-Err-Len
MOVE 0 TO WS-Ref-Len WS-Ref-Spaces
INSPECT In-Reference TALLYING
WS-Ref-Len FOR CHARACTERS BEFORE INITIAL SPACE
WS-Ref-Spaces FOR ALL SPACES
EVALUATE TRUE
WHEN WS-Ref-Len = 0
OR WS-Ref-Len + WS-Ref-Spaces < LENGTH OF In-Reference
MOVE 18 TO WS-Err-No
PERFORM Add-Error
WHEN In-Reference (1:WS-Ref-Len) IS NOT Ref-Char
MOVE 19 TO WS-Err-No
PERFORM Add-Error
END-EVALUATE
END-IF.
The reference check runs one INSPECT with two counters. WS-Ref-Len FOR CHARACTERS BEFORE INITIAL SPACE counts every character up to the first space, which is the length of a well-formed reference. WS-Ref-Spaces FOR ALL SPACES counts the spaces. In a good reference the two add up to the field's 16 bytes, because everything after the first space is padding. INV 2026 77 gives 3 and 7, which make 10, so something other than a space follows the first space, and record 11 gets E18. INV#5521 gives 8 and 8, passes that test, and the class test on In-Reference (1:8) finds the number sign (E19).
Two rules of INSPECT matter here. The first causes real bugs: TALLYING adds to its counter and never clears it. The guide's INSPECT section says the statement "will not zero-out identifier-2 at the start of execution of the INSPECT", IBM's reference says "You must initialize identifier-2 before execution of the INSPECT statement begins" (page 356), and the 3.2 runtime adds each count to the field it is given (cob_add_int in libcob/strings.c). Without the MOVE 0 statements, every count would start from whatever the previous INSPECT left in the field. The second rule covers several counters in one statement: a position counted by an earlier phrase is not counted again by a later one. The guide's example runs C-ABC FOR ALL "ABC" and C-BCDE FOR ALL "BCDE" over --ABCDEF----BCDEF-- and ends with 1 in each, because the first BCDE overlaps an ABC that was already counted. In this program the two phrases cannot compete, since CHARACTERS stops at the first space and ALL SPACES counts nothing else.
The guide's INSPECT syntax also offers TRAILING, which would count the padding directly. GnuCOBOL's own test suite files TRAILING under extensions, and IBM's TALLYING format has only CHARACTERS, ALL and LEADING (page 353), so the listing does without it.
Amounts: TEST-NUMVAL before NUMVAL
Check-Amount.
MOVE In-Amount TO WS-Err-Value
MOVE LENGTH OF In-Amount TO WS-Err-Len
MOVE FUNCTION TEST-NUMVAL (In-Amount) TO WS-Numval-Pos
IF WS-Numval-Pos NOT = 0
MOVE 9 TO WS-Err-No
MOVE WS-Numval-Pos TO WS-Err-Pos
PERFORM Add-Error
ELSE
COMPUTE WS-Amount-Wide = FUNCTION NUMVAL (In-Amount)
ON SIZE ERROR
MOVE 11 TO WS-Err-No
PERFORM Add-Error
NOT ON SIZE ERROR
MOVE WS-Amount-Wide TO WS-Amount
EVALUATE TRUE
WHEN WS-Amount NOT = WS-Amount-Wide
MOVE 10 TO WS-Err-No
PERFORM Add-Error
WHEN WS-Amount < 0.01 OR WS-Amount > 50000.00
MOVE 11 TO WS-Err-No
PERFORM Add-Error
END-EVALUATE
END-COMPUTE
END-IF.
TEST-NUMVAL answers one question: would NUMVAL accept this text? It returns zero if so; otherwise it returns the position of the first character in error, or the length plus one when the text ends before it is complete (guide 8.1.89; IBM page 651). The program prints that position after "at". 1,250.75 gives 2, the comma: IBM's NUMVAL formats on page 605 allow a comma only as the decimal point under DECIMAL-POINT IS COMMA, and in the 3.2 runtime only NUMVAL-C accepts a grouping comma. The guide's NUMVAL section calls the comma a grouping separator; the 3.2 NUMVAL skips it and raises an exception. $45.00 gives 1, and 12.5.0 gives 5, the second point. The companion's copy of the routine reproduces all 32 expected results in GnuCOBOL's TEST-NUMVAL test, and IBM's example that 0 1 returns 3.
Why test first, when NUMVAL could complain for itself? Because in 3.2 it does not refuse anything. The NEWS file for the release says that when the argument breaks the rules, "previous versions returned zero; now invalid data is skipped; for example: "1. A-0B4.5" now returns -1.045". The function sets the EC-ARGUMENT-FUNCTION exception, which a program has to look for itself; the same NEWS entry names checking it afterwards, or calling TEST-NUMVAL first, as the ways to catch bad input. Run on the three amounts TEST-NUMVAL refused, the companion's copy of 3.2's NUMVAL returns 1250.75, 45.00 and 12.50. Two of those are probably what the sender meant. The third is not, and nothing in the value says which is which.
Once the text passes, COMPUTE converts it into WS-Amount-Wide, PIC S9(7)V9(11), with ON SIZE ERROR, which the lesson on COMPUTE covers. Eleven decimal places hold every decimal a 12-byte field can carry, so the conversion loses nothing, and seven integer places hold anything up to the limit and well past it. Record 23's 100000000.00 overflows them, which is E11. The MOVE to WS-Amount, PIC S9(7)V99, then drops every decimal after the second, as the MOVE rules say it must, and if the two fields now differ, the text had more than two decimals: 19.999 becomes 19.99 and gets E10. The range test comes last. 0.00 and 60000 fail it, and so does 125.00-: a trailing minus is valid NUMVAL syntax, so TEST-NUMVAL passes it and NUMVAL makes it -125.00.
The comparison relies on NUMVAL producing an exact decimal, which GnuCOBOL's does: the 3.2 routine collects the digits as text and builds the number with the GMP library (numval in libcob/intrinsic.c). IBM's does not. Its Programming Guide says NUMVAL returns "long (64-bit) floating-point values in compatibility mode" and that "At most 15 decimal digits can be converted accurately" (page 115), and a floating-point value moved to a fixed-point field is rounded in its low-order position (page 52). We have not worked out how the comparison behaves under that conversion.
Dates: TEST-DATE-YYYYMMDD
Check-Date.
MOVE In-Value-Date-X TO WS-Err-Value
MOVE LENGTH OF In-Value-Date-X TO WS-Err-Len
IF In-Value-Date IS NOT NUMERIC
MOVE 12 TO WS-Err-No
PERFORM Add-Error
ELSE
MOVE FUNCTION TEST-DATE-YYYYMMDD (In-Value-Date)
TO WS-Date-Result
IF WS-Date-Result NOT = 0
COMPUTE WS-Err-No = 12 + WS-Date-Result
PERFORM Add-Error
END-IF
END-IF.
TEST-DATE-YYYYMMDD returns 0 for a valid date, and 1, 2 or 3 when the year, the month or the day is wrong. A valid year is 1601 to 9999, and the day is checked against the length of the month in that year, leap years included; the guide's section 8.1.86 and IBM's page 645 give the same codes. Adding 12 to the result picks E13, E14 or E15, so the error line says which part of the date is wrong. 20261399 returns 2: it is the date the bank-statement job let through. 20250229 returns 3, because 2025 is not a leap year, while record 19's 20240229 passes. 00000000 returns 1. The companion checks its copy against GnuCOBOL's own test, in which 20020231 returns 3, and against IBM's five examples.
The NUMERIC test comes first because the argument has to be an integer: IBM says "Must be an integer", and the guide asks for "a numeric integer data item or literal". 2026OCT1 stops there with E12. Leaving the test out would not produce an error in its place. The 3.2 NEWS file says that for invalid numeric DISPLAY data "only the second half-byte is used", so that a space and a zero both count as zero, and the routine that hands the function its argument, cob_display_get_int in libcob/move.c, works exactly that way. A date keyed as 2026 101, with a space for a zero, would reach TEST-DATE-YYYYMMDD as 20260101 and pass.
What READ has already decided
*> GnuCOBOL 3.2 returns a line longer than the record in pieces, the
*> first with status 06. Count lines, not reads, and drop the pieces.
Take-Record.
IF NOT Read-Usable
DISPLAY "Read failed after line " WS-Read
", status " WS-In-Status
MOVE 12 TO RETURN-CODE
STOP RUN
END-IF
IF In-Long-Line
IF WS-In-Status NOT = "06"
MOVE "N" TO WS-Long-Flag
END-IF
ELSE
ADD 1 TO WS-Read
IF WS-In-Status = "06"
SET In-Long-Line TO TRUE
END-IF
PERFORM Validate-Record
END-IF.
Two of the rules are settled before any field is looked at, by the way GnuCOBOL 3.2 reads LINE SEQUENTIAL files, and its NEWS file describes both. A character below the space, other than the tab, form feed, backspace, escape and shift-in that the runtime lets through, makes the READ return status 09, and a WRITE of such a character fails with status 71; the runtime option COB_LS_VALIDATE, on by default, controls the check. A line longer than the record is no longer cut quietly: "the data is returned as "multiple" records and a warning (status 06) is issued", and with COB_LS_SPLIT set to false the record is truncated with status 04 instead. Before 3.2, the NEWS file says, the data was cut and the status was 00.
Statuses 04, 06 and 09 all begin with 0, which makes them successful reads: the 3.2 runtime clears the exception code for any status in that class (save_status in libcob/fileio.c), so AT END is not taken and a program that tests only AT END carries on as if the record were normal. Take-Record looks at the status itself, in the FILE STATUS item that the sequential-file lesson introduced. Record 20 comes back as an 80-byte record with status 06, and a second read returns its last byte, a single space, padded to 80. Take-Record counts the line once, flags it E01 and drops the piece, so the record numbers in the error file stay equal to line numbers. Record 21 comes back with status 09 and gets E02. With COB_LS_SPLIT false, record 20 arrives once with status 04 and gets the same E01; the companion runs the file both ways and gets identical output.
Neither record is checked field by field. Every field of record 20 after the name is one byte off, and checking them would produce several errors about the wrong cause. Neither record is shown on its error line either: under the same default, writing the X'00' bytes of record 21 to a LINE SEQUENTIAL file would fail with status 71.
What the program writes
The companion's expected standard output:
Records read : 24
Records accepted : 6
Records rejected : 18
Errors written : 26
Accepted amount : 53,966.44
Errors by code:
E01 1 RECORD LONGER THAN 80 BYTES
E02 1 CONTROL CHARACTER IN RECORD
E03 1 ACCOUNT NOT NUMERIC
E04 1 ACCOUNT NOT ZERO-FILLED
E05 1 NAME MISSING
E06 1 NAME HAS A BAD CHARACTER
E07 2 CURRENCY NOT 3 CAPITALS
E08 1 CURRENCY NOT ACCEPTED
E09 3 AMOUNT NOT A NUMBER
E10 1 AMOUNT HAS OVER 2 DECIMALS
E11 4 AMOUNT NOT 0.01 TO 50000.00
E12 1 DATE NOT NUMERIC
E13 1 DATE YEAR NOT 1601-9999
E14 1 DATE MONTH NOT 01-12
E15 1 DATE DAY NOT IN MONTH
E16 2 TYPE NOT P OR R
E17 1 REFUND WITHOUT REFERENCE
E18 1 REFERENCE HAS A SPACE
E19 1 REFERENCE HAS BAD CHARACTER
And errors.txt, one line per error: the record number, the code, the message, the field between brackets and, for E09, the position TEST-NUMVAL reported.
3 E04 ACCOUNT NOT ZERO-FILLED [ 42178]
4 E03 ACCOUNT NOT NUMERIC [1000A219]
4 E06 NAME HAS A BAD CHARACTER [J0hn Brook ]
4 E16 TYPE NOT P OR R [C]
5 E09 AMOUNT NOT A NUMBER [1,250.75 ] at 2
5 E14 DATE MONTH NOT 01-12 [20261399]
6 E07 CURRENCY NOT 3 CAPITALS [usd]
6 E10 AMOUNT HAS OVER 2 DECIMALS [19.999 ]
7 E11 AMOUNT NOT 0.01 TO 50000.00 [0.00 ]
7 E15 DATE DAY NOT IN MONTH [20250229]
8 E08 CURRENCY NOT ACCEPTED [JPY]
8 E11 AMOUNT NOT 0.01 TO 50000.00 [60000 ]
9 E07 CURRENCY NOT 3 CAPITALS [US ]
9 E16 TYPE NOT P OR R [X]
10 E17 REFUND WITHOUT REFERENCE [ ]
11 E18 REFERENCE HAS A SPACE [INV 2026 77 ]
12 E19 REFERENCE HAS BAD CHARACTER [INV#5521 ]
13 E05 NAME MISSING [ ]
13 E12 DATE NOT NUMERIC [2026OCT1]
14 E13 DATE YEAR NOT 1601-9999 [00000000]
16 E11 AMOUNT NOT 0.01 TO 50000.00 [125.00- ]
17 E09 AMOUNT NOT A NUMBER [$45.00 ] at 1
18 E09 AMOUNT NOT A NUMBER [12.5.0 ] at 5
20 E01 RECORD LONGER THAN 80 BYTES
21 E02 CONTROL CHARACTER IN RECORD
23 E11 AMOUNT NOT 0.01 TO 50000.00 [100000000.00]
And accepted.dat, 67 bytes per record, with the amount as PIC 9(5)V99, so that 1,250.75 is written as 0125075:
10004217Ada Lovell USD012507520261001PINV-2026-0042
10004218Mae O'Neill EUR000752020260930RRF-88123
10004230Kofi Mensah-Bonsu Jr. GBP499999920261005P
10004234Lucia Ortega USD000005020240229PINV-2026-0055
10004237Chloe Martin EUR020000020261007RRF-88125
10004239Eva Novak USD006400020261008PINV-2026-0058
Three checks are worth doing by eye. Accepted and rejected add up to the 24 records read. The nineteen counts in the summary add up to the 26 error lines. And the six accepted amounts, 1,250.75, 75.20, 49,999.99, 0.50, 2,000.00 and 640.00, add up to 53,966.44. The third accepted line ends at its type, because record 15 has no reference and a LINE SEQUENTIAL WRITE drops trailing spaces by default (COB_LS_FIXED is false in the 3.2 runtime configuration). Because records were rejected, the program ends with RETURN-CODE 4; the guide's section on special registers describes RETURN-CODE as the item into which "a main program may MOVE a value before returning control to the operating system".
Compile and run
cobc -x -I code code/cobol-input-validation.cob
./cobol-input-validation
echo $?
python code/cobol-input-validation-class-inspect-test-numval.py errors.txt accepted.dat
Run the commands from the site folder, since the program opens its data file by a relative path, and -I code lets cobc find the copybook. echo $? should print 4. With the two file names, the companion compares the files the program wrote with its expected lines, ignoring trailing spaces, and prints any line that differs; with no arguments it prints the expected output and its own checks.
Where GnuCOBOL 3.2 and IBM differ
| Behaviour | GnuCOBOL 3.2 (source and tests) | IBM Enterprise COBOL 6.4 (page) |
|---|---|---|
| "A" THRU "Z" in a CLASS clause | the 26 capitals (ASCII) | 41 EBCDIC code points (129; Table 81, 751 to 754) |
| ALPHABETIC-UPPER | capitals and the space | capitals and the space (270) |
| INSPECT TALLYING FOR TRAILING | an extension | not in the format (353) |
TEST-NUMVAL on 0 1 |
3 | 3 (651) |
| TEST-DATE-YYYYMMDD results | 0, 1, 2 or 3 | 0, 1, 2 or 3 (645) |
| NUMVAL on text TEST-NUMVAL rejects | skips the bad characters and sets EC-ARGUMENT-FUNCTION | the argument must follow one of its formats (605) |
| Grouping comma in NUMVAL | skipped, with the exception; NUMVAL-C accepts it | not in either format (605) |
| NUMVAL result | an exact decimal | 64-bit floating point under ARITH(COMPAT), 128-bit under ARITH(EXTEND) (Programming Guide, 115) |
Limitations
- Compiled and run on one build. The expected output is the companion's, built from our reading of the 3.2 source, and a GnuCOBOL 3.2 build here reproduced it exactly on 29 October 2026. Its copies of TEST-NUMVAL, NUMVAL, TEST-DATE-YYYYMMDD, CLASS and INSPECT reproduce the expected results in GnuCOBOL's tests and IBM's examples; its copies of the LINE SEQUENTIAL read and of the numeric conversion rest on our reading of the source alone. If your GnuCOBOL build disagrees, the compiler wins, and the compare mode will show where.
- Bytes, not characters. The classes test bytes. A name stored in UTF-8 with an accented letter has bytes outside Name-Char and would get E06, so a feed with such names needs a different rule, and its record length counts bytes too.
- One error per field. A field with two faults shows only the first, and fixing it can reveal the second on the next run.
- Constants in the code. The currency list and the amount range are written into the listing, and the record length into the copybook. A production edit keeps them in a control file or a table, so they can change without a recompile.
- 3.2 defaults. The status handling follows the 3.2 defaults and COB_LS_SPLIT set to false. Under 3.1, record 20 would arrive with status 00 and every field after its name one byte off, and record 21's LOW-VALUES would reach the account check and get E03.
- The IBM side. The z/OS behaviour comes from IBM's manuals; there is no z/OS system here to try any of it on.
Correction, 29 October 2026: the build command shown before named the source cobol-input-validation-class-inspect-test-numval.cob, and GnuCOBOL 3.2's cobc refuses a source file whose base name is longer than 31 characters ("invalid file base name ... length exceeds maximum"), even with -o, so it failed as printed. The listing is now shipped as cobol-input-validation.cob, and the old download address redirects there. The same day we compiled and ran the program with GnuCOBOL 3.2, and all three of its outputs matched the expected output shown here line for line, so the page now says that instead of calling the listing desk-checked only.
Index terms cobol, gnucobol, validation, inspect, test-numval