COBOL Textbook

COBOL STRING and UNSTRING: splitting a CSV line into fields

A GnuCOBOL program that splits a comma-separated export with UNSTRING, checks each field with COUNT IN and TALLYING IN, and builds a label with STRING.

Published
Dialect
GnuCOBOL 3.x, free format
Status
Desk-checked, not compiled
Length
2,186 words, 10 min

Sooner or later a COBOL program is handed a file that was not designed for COBOL: a spreadsheet export, a CSV from a web form, a list somebody typed. Its fields are not in fixed columns, they are separated by commas, and every line is a different length. UNSTRING is the statement that takes such a line apart, and STRING is the one that puts text back together. This lesson builds one program that does both for a small customer export. It splits each line into five fields, uses the extra phrases of UNSTRING (COUNT IN, DELIMITER IN, TALLYING IN, ON OVERFLOW) to find out what actually happened during the split, walks a list inside one field with a POINTER, and builds a fixed-width label with STRING that knows when it ran out of room.

What was run and what was not. cobc is not installed on the machine this lesson was prepared on, so the listing has not been compiled. We desk-checked it against the GnuCOBOL Programmer's Guide (which documents the July 2020 build of GnuCOBOL 3.1 RC-1) and against the source code and test suite of the GnuCOBOL 3.2 release, downloaded from the GNU project's server on October 12, 2026, where 3.2 is still the newest release listed. The expected output further down comes from the Python companion, /code/cobol-string-unstring.py. It copies the STRING and UNSTRING routines from the 3.2 runtime (libcob/strings.c) line by line, passes 30 checks taken from the cases in GnuCOBOL's own test suite, and then walks the data file the way the COBOL program does. If a compiled run ever disagrees with it, the compiler wins and this page is wrong.

The file

The data file, /datasets/cobol-string-unstring.csv, is twelve lines: a header, ten made-up customers and one blank line. Each customer line should have five fields:

Table 5.4-1
Field Receiving item Size Rule
cust_id Cust-Id X(6) six digits
last_name Cust-Last X(15) present, at most 15 characters
first_name Cust-First X(12) present, at most 12 characters
city Cust-City X(15) present, at most 15 characters
tags Cust-Tags X(40) zero or more tags separated by a vertical bar

The lines were written to hit the cases that break a naive split. One last name has a space in it (Van Dyke). One is 17 characters long. One line ends with a comma, one has an empty field in the middle, one has only four fields, one has a comma inside the city ("Washington, DC"), one wraps a name in quotes the way a spreadsheet does, one has two bars in a row in its tags, and one produces a label too long for the 32 characters set aside for it.

Finding where the line ends

A LINE SEQUENTIAL read fills the record area with the line and pads the rest with spaces. That is not just a habit of the guide's examples: lineseq_read in the 3.2 file handler ends with a memset of spaces from the last byte read to the end of the record, and it drops a carriage return that comes before the line feed, so the same file works with Windows or Unix line endings. The record here is 120 bytes, so a 45-byte line arrives followed by 75 spaces. If those spaces are left in the source of the UNSTRING they become part of the last field, and its COUNT IN reports them too.

The program measures the line first:

Find-Length.
    MOVE FUNCTION REVERSE (Csv-Line) TO WS-Reversed
    MOVE 0 TO WS-Trail
    INSPECT WS-Reversed TALLYING WS-Trail FOR LEADING SPACES
    COMPUTE WS-Line-Len = FUNCTION LENGTH (Csv-Line) - WS-Trail.
Listing 5.4-1. Lines 165 to 169 of cobol-string-unstring.cob.

Reversed, the padding is at the front, and INSPECT counts it. A blank line gives a length of 0 and is skipped before anything uses the length, because a reference modification of length zero is not allowed. Every later statement uses Csv-Line (1:WS-Line-Len), the line without its padding.

The split, and what it reports

    UNSTRING Csv-Line (1:WS-Line-Len)
        DELIMITED BY ","
        INTO Cust-Id    COUNT IN Len-Id
             Cust-Last  COUNT IN Len-Last
             Cust-First COUNT IN Len-First
             Cust-City  COUNT IN Len-City
             Cust-Tags  DELIMITER IN WS-Tags-Delim
                        COUNT IN Len-Tags
        TALLYING IN WS-Fields
        ON OVERFLOW
            SET Extra-Fields TO TRUE
    END-UNSTRING
Listing 5.4-2. Lines 185 to 196 of cobol-string-unstring.cob.

UNSTRING scans from the left until it finds a comma, moves what it passed over into the next INTO field and carries on after the comma. The move follows the MOVE rules, as the guide's section 7.8.49 says: a shorter value is padded with spaces and a longer one is cut off on the right with no warning. This is the same silent truncation the MOVE lesson warned about, and it is why every field here has a COUNT IN. The count is the number of characters found before the delimiter, not the number that fitted, so "Abernathy-Wolcott" gives a count of 17 while Cust-Last holds only its first 15 characters. Comparing the count with the field size is the only way to notice.

The other phrases answer the other questions:

  • TALLYING IN adds one for each receiving field the statement actually filled. The guide notes that UNSTRING initialises none of its counters, so the program sets WS-Fields to zero first, and INITIALIZE clears the five fields and five counts before every line.
  • DELIMITER IN receives the delimiter that ended the field. When a field runs to the end of the source, it is set to spaces instead (IBM's Language Reference, page 470, and the 3.2 runtime agree). For the last field that is a useful signal: spaces mean the line ended where it should, a comma means it did not.
  • ON OVERFLOW runs when every receiving field has been filled and some of the source is still unexamined. A line with six fields triggers it.

The guide's description also says what two commas in a row do: they "identify a null sub string". Moreau's line, 100105,Moreau,,Lyon,retail|weekly, gives Cust-First all spaces and a count of zero, which the checks turn into FIRST NAME IS MISSING.

The comma at the end of the line

The fourth line is 100103,Lindqvist,Per,Uppsala,, a customer with no tags. RFC 4180, the closest thing CSV has to a definition, says the last field of a record must not be followed by a comma, so a trailing comma means one more field, and that field is empty. The scan finds the fourth comma as the very last character, puts Uppsala into Cust-City, and moves its position past the comma, which is now past the end of the source. In the 3.2 runtime, cob_unstring_into returns at once when the position has reached the end, so Cust-Tags is never touched, its COUNT IN is not set and TALLYING does not count it. WS-Fields comes out as 4. IBM's description of the data flow says the same thing in general terms: examining and moving repeat until all the characters in the sending field have been transferred or there are no more receiving fields (page 470). There is no overflow either, since nothing is left over.

A program that trusts the tally would reject Lindqvist for having four fields. This one checks the last character:

    IF Csv-Line (WS-Line-Len:1) = "," AND WS-Fields < 5
        ADD 1 TO WS-Fields
        SET Trailing-Comma TO TRUE
    END-IF.
Listing 5.4-3. Lines 200 to 203 of cobol-string-unstring.cob.

The rule is general, not a special case for the tags column: a line ending in a delimiter has one more field than the tally says, and that field is empty. Because Split-Line initialised everything first, the untouched field already holds spaces and its count is already zero.

Checking the fields

Check-Fields is a single EVALUATE that stops at the first problem, the approach the bank-statement job used. It rejects a line with a quote character, a line that overflowed, a line with fewer than five fields, an ID that is not six digits, and any name or city that is missing (count 0) or too long (count above the field size). The input validation lesson shows how to report every error in a record instead.

The quote check comes before the split on purpose. In 100108,"Brennan, Jr.",Liam,Cork,retail the comma inside the quotes is data, but UNSTRING has no idea what a quote is: it would see six fields and report an overflow, which describes the symptom and not the cause. INSPECT counts the quotes first, and the line is rejected with a message that says what is unsupported.

The reject messages themselves are built with STRING, which brings up the first rule of STRING: it never clears its target. The guide's section 7.8.43 says the receiving item is neither initialised at the start nor space-filled at the end, so whatever was there before stays after the last character copied. WS-Reason is set to spaces before every line for that reason.

Building the label with STRING

    STRING Cust-Last (1:Len-Last)   DELIMITED BY SIZE
           ", "                     DELIMITED BY SIZE
           Cust-First (1:Len-First) DELIMITED BY SIZE
           " ("                     DELIMITED BY SIZE
           Cust-City (1:Len-City)   DELIMITED BY SIZE
           ")"                      DELIMITED BY SIZE
        INTO WS-Label
        WITH POINTER WS-Label-Ptr
        ON OVERFLOW
            SET Label-Cut TO TRUE
    END-STRING
Listing 5.4-4. Lines 263 to 273 of cobol-string-unstring.cob.

Each source is cut to its real length with the count UNSTRING returned, so no padding gets in between the pieces. The usual shortcut is DELIMITED BY SPACE, which copies a field up to its first space. It works until a name contains a space, and the program builds that version too, purely to show the difference: for Van Dyke it gives "Van, Marta".

The POINTER starts at 1 and STRING moves it one place for every character copied. In the 3.2 runtime, when a source does not fit, cob_string_append copies the characters that do fit, sets the overflow exception and skips every later source. At the end the pointer is the next free position. Fitzgerald's label needs 37 characters; the first 32 arrive, the closing parenthesis never does, and the pointer is 33, one past the end of WS-Label. IBM's usage note on page 461 is the reason WS-Label-Ptr is PIC 9(3) and not PIC 99 or smaller: the pointer must be able to hold the length of the receiving field plus one. GnuCOBOL 3.2 does not check this when it compiles; the test suite notes that the check, which would reject a PIC 9 pointer for a 9-byte target, arrives in the 4.x line.

A pointer that starts outside the target is also an overflow, and then nothing at all is copied. The 3.2 test suite checks exactly that: with a 3-byte target, starting values of 0 and 4 both leave the target unchanged and the pointer where it was.

Walking the tags with a POINTER

The tags field is a list inside a field. The program takes it apart one tag at a time:

        PERFORM UNTIL WS-Tag-Ptr > Len-Tags
            MOVE SPACES TO WS-One-Tag
            MOVE 0 TO WS-One-Len
            UNSTRING Cust-Tags (1:Len-Tags)
                DELIMITED BY "|"
                INTO WS-One-Tag COUNT IN WS-One-Len
                WITH POINTER WS-Tag-Ptr
            END-UNSTRING
Listing 5.4-5. Lines 295 to 302 of cobol-string-unstring.cob.

Each UNSTRING starts at the pointer, stops at the next bar, and leaves the pointer just after it, or one past the end when no bar is left. The loop always advances (by at least the bar), so it cannot run forever. With only one INTO field, every tag but the last leaves source unexamined, which is an overflow condition; there is no ON OVERFLOW phrase, so it is ignored, as intended. Tanaka's paper||weekly produces a tag with a count of zero in the middle, which the program skips and reports. The same rule as the comma at the end of the line applies here: retail| would yield one tag, not two.

The run

This is the output the companion works out for the data file. It is the expected output of the COBOL program, not a captured run:

LINE   1 HEADER
LINE   2 OK     Okafor, Grace (Leeds)            TAGS=3 [retail][paper][weekly]
LINE   3 OK     Van Dyke, Marta (Rotterdam)      TAGS=1 [wholesale]
             NOTE: DELIMITED BY SPACE WOULD GIVE [Van, Marta]
LINE   4 OK     Lindqvist, Per (Uppsala)         TAGS=0 (none)
             NOTE: TRAILING COMMA, TAGS FIELD IS EMPTY
LINE   5 REJECT LAST NAME HAS 17 CHARACTERS, THE FIELD HOLDS 15
LINE   6 REJECT FIRST NAME IS MISSING
LINE   7 REJECT ONLY 4 FIELDS, EXPECTED 5
LINE   8 REJECT MORE THAN 5 FIELDS (THE TAGS FIELD ENDED AT ',')
LINE   9 BLANK, SKIPPED
LINE  10 REJECT QUOTED FIELD; THIS PROGRAM DOES NOT PARSE QUOTES
LINE  11 OK     Tanaka, Yui (Kyoto)              TAGS=2 [paper][weekly]
             NOTE: 1 EMPTY TAG(S) SKIPPED
LINE  12 OK     Fitzgerald, Annabelle (Middlesbr TAGS=2 [retail][weekly]
             NOTE: LABEL CUT AT 32 CHARACTERS, POINTER = 33
----
LINES READ    12
ACCEPTED       5
REJECTED       5
BLANK          1

The totals account for every line: one header, five accepted, five rejected and one blank make twelve. Line 8 is the Washington, DC customer: the split put "Washington" in Cust-City and " DC" in Cust-Tags, the tags field ended at a comma instead of the end of the line, and the government tag was left unexamined, so ON OVERFLOW ran. Because one or more lines were rejected, the program ends with return code 4.

Compile and run

From the site folder, with GnuCOBOL 3.x installed:

cobc -x code/cobol-string-unstring.cob
./cobol-string-unstring
python code/cobol-string-unstring.py
python code/cobol-string-unstring.py --selftest

The first two commands should print the listing above; the third prints the companion's version for comparison, and the fourth lists the 30 test-suite checks. The program reads the data file by a relative path, so run it from the site folder.

Limitations

  • Not compiled here. The listing was checked against the guide and the 3.2 source, not by a compiler, and the output above was worked out by the companion.
  • Not a CSV parser. RFC 4180 encloses a field in double quotes when it contains a comma, a double quote or a line break, and doubles any quote inside it. UNSTRING cannot do any of that, so this program rejects every quote. A real CSV feed needs a character-by-character state machine, or an agreement with the sender to use a delimiter that never appears in the data, such as a vertical bar or a tab.
  • First error only. A line with two problems reports the first one.
  • Leading and trailing spaces inside a field are kept. Leeds ,retail would put "Leeds " into the city with a count of 6. RFC 4180 says spaces are part of a field and should not be ignored, so keeping them is correct; if a sender pads its fields anyway, FUNCTION TRIM can clean them after the split.
  • IBM is stricter about the phrases. IBM's Language Reference (page 467) forbids DELIMITER IN and COUNT IN on an UNSTRING that has no DELIMITED BY; a comment in the 3.2 runtime notes that GnuCOBOL's parser has always allowed them. This program only uses them with DELIMITED BY, so it stays portable.

The habit worth keeping from this lesson is to treat COUNT IN and TALLYING IN as part of the statement rather than as options. A split that does not report what it found has no way to tell a short name from a truncated one, or an empty last column from a missing one.

Index terms cobol, gnucobol, unstring, string, csv