A COBOL bank-statement batch job: read, validate, total, report
A complete GnuCOBOL batch program that reads sorted transactions, rejects bad records to a file, breaks on account change, and writes a statement with totals.
This is the shape of most batch COBOL that has ever run: read a sorted file, check each record, send the bad ones somewhere a person will look, accumulate totals at one or more levels, and write a report. You get the whole thing here as one program of about 200 lines, a twelve-record transaction file with two deliberate faults in it, and a Python script that computes the statement the program is written to produce. The two files it writes, statement.txt and rejects.dat, are the two outputs a bank operations team would actually pick up.
The listing was desk-checked against the GnuCOBOL 3.2 Programmer's Guide, and the expected statement below was produced by running the Python companion, /code/cobol-bank-statement-batch-job.py, which applies the same rules with the same edited layouts. On 29 October 2026 we compiled and ran the program with GnuCOBOL 3.2, and both files it wrote matched the companion's lines exactly. The program leans on one earlier lesson, reading a sequential file to end of file, for the read loop; the WRITE FROM pattern is explained where it appears below.
Record layout
The transaction file has 72 bytes per line and a line feed. The copybook in /code/cobol-bank-statement-batch-job.cpy is the COBOL description; the file is sorted by account and then date, which the control break depends on.
| Field | Columns | PIC | Meaning |
|---|---|---|---|
| Txn-Account | 1 to 8 | 9(8) | Account number |
| Txn-Date | 9 to 16 | 9(8) | YYYYMMDD |
| Txn-Type | 17 | X(1) | D debit, C credit |
| Txn-Amount | 18 to 28 | 9(9)V99 | Amount in cents, no point |
| Txn-Desc | 29 to 68 | X(40) | Description |
| FILLER | 69 to 72 | X(4) | Unused |
Two records are wrong on purpose. Record three carries the amount 00000012.50, with a decimal point where a digit belongs, and record seven has the type X. The program must reject both without touching the totals.
The program
>>SOURCE FORMAT IS FREE
*> cobol-bank-statement-batch-job.cob — read a sorted transaction file,
*> validate every record, reject the bad ones, and write a statement with
*> one section per account and grand totals at the end
*> Dialect: GnuCOBOL 3.x, free format
*> Status : desk-checked against the GnuCOBOL 3.2 Programmer's Guide;
*> compiled and run with GnuCOBOL 3.2 on 2026-10-29
*> Inputs : datasets/cobol-bank-statement-batch-job.dat
*> (LINE SEQUENTIAL, LF line endings, 72 bytes per record,
*> sorted by account then date)
*> Outputs: statement.txt (the report) and rejects.dat (bad records)
*> in the current folder; counts on standard output
*> Build : cobc -x -I code code/cobol-bank-statement-batch-job.cob
*> Run : ./cobol-bank-statement-batch-job (from the site folder)
IDENTIFICATION DIVISION.
PROGRAM-ID. BANK-STATEMENT.
ENVIRONMENT DIVISION.
INPUT-OUTPUT SECTION.
FILE-CONTROL.
SELECT Txn-File
ASSIGN TO "datasets/cobol-bank-statement-batch-job.dat"
ORGANIZATION IS LINE SEQUENTIAL
FILE STATUS IS WS-Txn-Status.
SELECT Stmt-File
ASSIGN TO "statement.txt"
ORGANIZATION IS LINE SEQUENTIAL
FILE STATUS IS WS-Stmt-Status.
SELECT Reject-File
ASSIGN TO "rejects.dat"
ORGANIZATION IS LINE SEQUENTIAL
FILE STATUS IS WS-Reject-Status.
DATA DIVISION.
FILE SECTION.
FD Txn-File.
COPY "cobol-bank-statement-batch-job.cpy".
FD Stmt-File.
01 Stmt-Line PIC X(100).
FD Reject-File.
01 Reject-Line PIC X(105).
WORKING-STORAGE SECTION.
01 WS-Statuses.
05 WS-Txn-Status PIC X(2).
05 WS-Stmt-Status PIC X(2).
05 WS-Reject-Status PIC X(2).
01 WS-Eof-Flag PIC X VALUE "N".
88 End-Of-File VALUE "Y".
01 WS-Account-Flag PIC X VALUE "N".
88 Account-Open VALUE "Y".
01 WS-Current-Account PIC 9(8) VALUE 0.
01 WS-Reason PIC X(30).
01 WS-Amounts.
05 WS-Balance PIC S9(11)V99 VALUE 0.
05 WS-Acct-Debits PIC 9(11)V99 VALUE 0.
05 WS-Acct-Credits PIC 9(11)V99 VALUE 0.
05 WS-Grand-Debits PIC 9(11)V99 VALUE 0.
05 WS-Grand-Credits PIC 9(11)V99 VALUE 0.
01 WS-Counts.
05 WS-Read-Count PIC 9(6) VALUE 0.
05 WS-Accept-Count PIC 9(6) VALUE 0.
05 WS-Reject-Count PIC 9(6) VALUE 0.
05 WS-Account-Count PIC 9(6) VALUE 0.
01 WS-Title-Line PIC X(14) VALUE "BANK STATEMENT".
01 WS-Account-Header.
05 FILLER PIC X(8) VALUE "Account ".
05 WS-AH-Account PIC 9(8).
01 WS-Column-Header.
05 FILLER PIC X(2) VALUE SPACES.
05 FILLER PIC X(10) VALUE "Date".
05 FILLER PIC X(2) VALUE SPACES.
05 FILLER PIC X(1) VALUE "T".
05 FILLER PIC X(2) VALUE SPACES.
05 FILLER PIC X(30) VALUE "Description".
05 FILLER PIC X(1) VALUE SPACES.
05 FILLER PIC X(16) VALUE " Amount".
05 FILLER PIC X(1) VALUE SPACES.
05 FILLER PIC X(16) VALUE " Balance".
01 WS-Detail-Line.
05 FILLER PIC X(2) VALUE SPACES.
05 WS-DL-Date PIC 9(4)/9(2)/9(2).
05 FILLER PIC X(2) VALUE SPACES.
05 WS-DL-Type PIC X(1).
05 FILLER PIC X(2) VALUE SPACES.
05 WS-DL-Desc PIC X(30).
05 FILLER PIC X(1) VALUE SPACES.
05 WS-DL-Amount PIC Z,ZZZ,ZZZ,ZZ9.99.
05 FILLER PIC X(1) VALUE SPACES.
05 WS-DL-Balance PIC -,---,---,--9.99.
01 WS-Acct-Summary.
05 FILLER PIC X(11) VALUE " Debits ".
05 WS-AS-Debits PIC Z,ZZZ,ZZZ,ZZ9.99.
05 FILLER PIC X(11) VALUE " Credits ".
05 WS-AS-Credits PIC Z,ZZZ,ZZZ,ZZ9.99.
01 WS-Total-Line.
05 FILLER PIC X(17) VALUE " Closing balance".
05 FILLER PIC X(48) VALUE SPACES.
05 WS-TL-Balance PIC -,---,---,--9.99.
01 WS-Grand-Count-Line.
05 WS-GC-Label PIC X(22).
05 WS-GC-Count PIC ZZZ,ZZ9.
01 WS-Grand-Amount-Line.
05 WS-GA-Label PIC X(22).
05 WS-GA-Amount PIC Z,ZZZ,ZZZ,ZZ9.99.
01 WS-Reject-Data.
05 WS-RJ-Record PIC X(72).
05 FILLER PIC X(3) VALUE " | ".
05 WS-RJ-Reason PIC X(30).
PROCEDURE DIVISION.
Main-Paragraph.
PERFORM Open-Files
PERFORM Read-Txn
PERFORM UNTIL End-Of-File
PERFORM Process-Txn
PERFORM Read-Txn
END-PERFORM
IF Account-Open
PERFORM Account-Break
END-IF
PERFORM Write-Grand-Totals
CLOSE Txn-File Stmt-File Reject-File
DISPLAY "Read " WS-Read-Count " accepted " WS-Accept-Count
" rejected " WS-Reject-Count
" accounts " WS-Account-Count
STOP RUN.
Open-Files.
OPEN INPUT Txn-File
IF WS-Txn-Status NOT = "00"
DISPLAY "Cannot open transactions, status " WS-Txn-Status
STOP RUN
END-IF
OPEN OUTPUT Stmt-File Reject-File
WRITE Stmt-Line FROM WS-Title-Line.
Read-Txn.
READ Txn-File
AT END
SET End-Of-File TO TRUE
END-READ.
Process-Txn.
ADD 1 TO WS-Read-Count
MOVE SPACES TO WS-Reason
EVALUATE TRUE
WHEN Txn-Account IS NOT NUMERIC
MOVE "ACCOUNT NOT NUMERIC" TO WS-Reason
WHEN Txn-Date IS NOT NUMERIC
MOVE "DATE NOT NUMERIC" TO WS-Reason
WHEN Txn-Amount IS NOT NUMERIC
MOVE "AMOUNT NOT NUMERIC" TO WS-Reason
WHEN NOT Txn-Type-Valid
MOVE "UNKNOWN TYPE" TO WS-Reason
END-EVALUATE
IF WS-Reason NOT = SPACES
PERFORM Write-Reject
ELSE
IF NOT Account-Open
OR Txn-Account NOT = WS-Current-Account
IF Account-Open
PERFORM Account-Break
END-IF
PERFORM Account-Start
END-IF
PERFORM Post-Txn
END-IF.
Account-Start.
MOVE Txn-Account TO WS-Current-Account
SET Account-Open TO TRUE
ADD 1 TO WS-Account-Count
MOVE 0 TO WS-Balance WS-Acct-Debits WS-Acct-Credits
MOVE SPACES TO Stmt-Line
WRITE Stmt-Line
MOVE WS-Current-Account TO WS-AH-Account
WRITE Stmt-Line FROM WS-Account-Header
WRITE Stmt-Line FROM WS-Column-Header.
Post-Txn.
ADD 1 TO WS-Accept-Count
IF Txn-Credit
ADD Txn-Amount TO WS-Balance WS-Acct-Credits WS-Grand-Credits
ELSE
SUBTRACT Txn-Amount FROM WS-Balance
ADD Txn-Amount TO WS-Acct-Debits WS-Grand-Debits
END-IF
MOVE Txn-Date TO WS-DL-Date
MOVE Txn-Type TO WS-DL-Type
MOVE Txn-Desc TO WS-DL-Desc
MOVE Txn-Amount TO WS-DL-Amount
MOVE WS-Balance TO WS-DL-Balance
WRITE Stmt-Line FROM WS-Detail-Line.
Account-Break.
MOVE WS-Acct-Debits TO WS-AS-Debits
MOVE WS-Acct-Credits TO WS-AS-Credits
WRITE Stmt-Line FROM WS-Acct-Summary
MOVE WS-Balance TO WS-TL-Balance
WRITE Stmt-Line FROM WS-Total-Line
MOVE "N" TO WS-Account-Flag.
Write-Reject.
ADD 1 TO WS-Reject-Count
MOVE Txn-Record TO WS-RJ-Record
MOVE WS-Reason TO WS-RJ-Reason
WRITE Reject-Line FROM WS-Reject-Data.
Write-Grand-Totals.
MOVE SPACES TO Stmt-Line
WRITE Stmt-Line
MOVE "Accounts :" TO WS-GC-Label
MOVE WS-Account-Count TO WS-GC-Count
WRITE Stmt-Line FROM WS-Grand-Count-Line
MOVE "Transactions accepted:" TO WS-GC-Label
MOVE WS-Accept-Count TO WS-GC-Count
WRITE Stmt-Line FROM WS-Grand-Count-Line
MOVE "Transactions rejected:" TO WS-GC-Label
MOVE WS-Reject-Count TO WS-GC-Count
WRITE Stmt-Line FROM WS-Grand-Count-Line
MOVE "Total debits :" TO WS-GA-Label
MOVE WS-Grand-Debits TO WS-GA-Amount
WRITE Stmt-Line FROM WS-Grand-Amount-Line
MOVE "Total credits :" TO WS-GA-Label
MOVE WS-Grand-Credits TO WS-GA-Amount
WRITE Stmt-Line FROM WS-Grand-Amount-Line.
Line by line
Three SELECT entries name the input and the two outputs. All three are LINE SEQUENTIAL, so the statement and the reject file are ordinary text that any editor can open. Each has its own FILE STATUS item, because a batch job that cannot open its input should say so and stop rather than report zero transactions as if that were a result.
The FILE SECTION holds the input record through the copybook and two plain 01 records for the outputs. Everything the program builds is assembled in WORKING-STORAGE and written with WRITE FROM, which the Programmer's Guide and the IBM reference both define as a MOVE to the record area followed by the WRITE. The report lines are group items made of FILLERs with VALUE clauses and named fields, so each line's layout is visible in the data division rather than scattered through the logic.
The main paragraph is the priming-read pattern. Read-Txn is performed once before the loop, so that the loop condition can be tested against a real record, and again at the end of every iteration. The UNTIL condition is the level-88 End-Of-File, which the AT END branch of READ sets. Without the priming read the loop would process an empty record area on its first pass, which is the source of a great many off-by-one reports.
Process-Txn validates first. An EVALUATE TRUE lists the tests in order of severity and stops at the first that fails; the Programmer's Guide describes the statement as picking the first WHEN whose condition is true. The class tests come from the same guide's rule that only the digits pass NUMERIC, so the record with the decimal point in its amount fails there. The type is checked through the level-88 Txn-Type-Valid, whose VALUE lists both acceptable letters. If a reason was set, the record goes to Write-Reject, which writes the raw 72 bytes followed by a separator and the reason. Nothing about a rejected record reaches the totals.
The control break is the next block. An account change is detected by comparing the record's account with WS-Current-Account; the first record of the file is handled by the Account-Open flag, which is false until the first account starts. On a change, the old account is closed by Account-Break, which writes its debit and credit summary and its closing balance, and then Account-Start resets the per-account accumulators and writes the account header and the column heading. This only works because the file is sorted by account: a second run of account 10002345 later in the file would open a second section for it. The sort order is a precondition of the job, not something the program checks.
Post-Txn applies the transaction. A credit adds to the running balance; a debit subtracts from it; both add to their side's per-account and grand totals in one ADD each, since ADD accepts several receiving fields. The detail line is then built by MOVE. The date goes into a 9(4)/9(2)/9(2) field, which inserts the two slashes exactly as the Programmer's Guide's PICTURE section shows for a date. The 40-character description moves into a 30-character field and is truncated on the right, which is the alphanumeric MOVE rule; the sample descriptions are all shorter than 30, so nothing is lost here. The balance goes into a -,---,---,--9.99 field whose floating minus appears only when the balance is negative, which happens twice in the sample.
Compile and run
python code/cobol-bank-statement-batch-job.py
cobc -x -I code code/cobol-bank-statement-batch-job.cob
./cobol-bank-statement-batch-job
cat statement.txt
cat rejects.dat
python code/cobol-bank-statement-batch-job.py statement.txt rejects.dat
Run from the site folder so that the relative path to the data file resolves. The -I code option lets the compiler find the copybook. The first command prints the expected statement and rejects; the last one compares the two files the program wrote with them, ignoring trailing spaces, and prints any line that differs.
Worked example
The Python run on this machine produced the statement below, with trailing spaces trimmed from each line. The compiled COBOL program wrote the same lines in the same order.
BANK STATEMENT
Account 10002345
Date T Description Amount Balance
2026/09/01 C Salary September 2,500.00 2,500.00
2026/09/03 D Card purchase grocer 45.99 2,454.01
2026/09/10 D Rent 800.00 1,654.01
Debits 845.99 Credits 2,500.00
Closing balance 1,654.01
Account 10002346
Date T Description Amount Balance
2026/09/02 C Transfer in 100.00 100.00
2026/09/04 D Cash withdrawal 150.00 -50.00
Debits 150.00 Credits 100.00
Closing balance -50.00
Account 10002350
Date T Description Amount Balance
2026/09/01 D Streaming subscription 19.99 -19.99
2026/09/15 C Refund 50.00 30.01
Debits 19.99 Credits 50.00
Closing balance 30.01
Account 10002377
Date T Description Amount Balance
2026/09/07 C Invoice 4471 paid 12,345.67 12,345.67
2026/09/08 D Supplier payment 9,999.99 2,345.68
2026/09/20 D Bank fee 123.45 2,222.23
Debits 10,123.44 Credits 12,345.67
Closing balance 2,222.23
Accounts : 4
Transactions accepted: 10
Transactions rejected: 2
Total debits : 11,139.42
Total credits : 14,995.67
The reject file holds the two faulty records with their reasons, and the console line at the end of the run should read Read 000012 accepted 000010 rejected 000002 accounts 000004, with the leading zeros because the counters are unedited PIC 9(6) fields.
1000234520260905D00000012.50Card purchase bad amount | AMOUNT NOT NUMERIC
1000234620260906X00000000500Unknown type | UNKNOWN TYPE
Two arithmetic checks tie the report together. The grand totals equal the sum of the four per-account debit and credit lines, and each closing balance equals that account's credits minus its debits, which is what a reader of the statement would check first.
Limitations
The program was compiled and run with GnuCOBOL 3.2 only. In that run, an MSYS2 MinGW build on Windows, the lines of statement.txt came out with their trailing spaces removed and ending in a carriage return and line feed; another build may pad them to the 100-byte record or end them with a line feed alone, so compare the output with trailing spaces trimmed, as the companion does. The program trusts that the file is sorted and does not detect a sequence error; a real job would either sort the file first or test that each account is not lower than the last and reject the run if it is. The validation also accepts any eight digits as a date, so 20261399 passes. There is no page handling, which the report-file lesson covers, and there are no opening balances: every account starts at zero because the sample carries no balance-forward record. Amount fields of 9(11)V99 hold a hundred billion, and the additions to them are unguarded; the lesson on ON SIZE ERROR is where that guard comes from.
Correction, 29 October 2026: the first build command ran python code/cobol-bank-statement-batch-job.py, but that companion script was never shipped. It is now: it reads the data file with the copybook's layout, applies the program's rules and prints the statement and the rejects shown above, and given the two output files it compares them line by line. The same day we compiled and ran the program with GnuCOBOL 3.2; both files matched the output shown here exactly, so the page now says that instead of calling the listing desk-checked only.
Index terms cobol, gnucobol, batch, control-break, validation