File Handling · intermediate · ~12 min
## What you will learn - Split a clean CSV line into individual fields using `strtok_r`, the re-entrant tokenizer. - Explain exactly how the destructive tokenizer mutates your buffer and why that matters. - Handle the cases that break naive comma-splitting: quoted fields containing commas, escaped quotes, and trailing empty fields. - Strip line endings correctly for both Unix (`\n`) and Windows (`\r\n`) files. - Write a small state-machine parser that respects quotes, and copy fields safely into bounded buffers. - Decide when to hand-roll a parser and when to reach for a tested library such as `libcsv`.
CSV stands for Comma-Separated Values. It is one of the oldest and most common ways to move tabular data between programs: spreadsheets, databases, log exporters, bank statements, and scientific instruments all speak CSV. Each line of text is one record (think: one row), and within a line the values are separated by commas into fields (think: columns).
A tiny CSV file looks like this:
name,age,city
Alice,30,Paris
Bob,25,Berlin
The appeal is that it is just plain text. You can open it in any editor, and reading it in C is mostly an exercise in the skills you already have. This lesson builds directly on fgets for safe line reading (you read the file one bounded line at a time) and on C strings (each field is a NUL-terminated char array, and you manipulate it with pointers and the <string.h> functions).
CSV looks trivial: read a line, split on commas, done. For perfectly clean data that is true. But real CSV files are produced by spreadsheets and other tools that follow a convention (loosely described by RFC 4180) where a field may be wrapped in double quotes so it can itself contain a comma, a quote, or even a newline:
id,full_name,note
1,"Smith, John","He said ""hi"""
Here line 2 has exactly three fields, even though it contains three commas and several quote characters. A naive split on , would wrongly report five fields. Most of this lesson is about understanding that gap between the simple model and real data, and writing code that survives it.
;." so special characters inside it are treated as data." inside a quoted field, written as two quotes "".CSV is the universal lowest-common-denominator data format. If you write software that imports user-uploaded data, exports reports, ingests bank or payment records, or feeds a data pipeline, you will parse CSV. Getting it wrong is not a cosmetic bug — it shifts every column after the mistake, so an address ends up in the phone-number field and a price lands in the quantity column. That kind of error is silent: the program does not crash, it just stores wrong data.
In C specifically, CSV parsing is a focused way to practice the disciplines that prevent the most common security and stability bugs: reading input with a fixed bound, never assuming a field fits a buffer, and treating every byte of external input as untrusted. A loose strcpy of a CSV field into a fixed array is a textbook buffer overflow. So even though CSV is not a "security topic", parsing it well is exactly the muscle you use to write safe input handling everywhere else.
Definition. The naive model treats a CSV line as "text split on commas": every comma is a field boundary, and the pieces between commas are the fields.
For clean data it is correct and one line of code:
char *tok = strtok(line, ",");
while (tok) { /* use tok */ tok = strtok(NULL, ","); }
Why it is tempting: it is short, and it works on the data you typed by hand to test it. Why it fails: real exporters quote fields, and a quoted field can contain commas, quotes, and newlines. The naive model has no concept of "inside a quote", so it cuts those fields apart.
Knowledge check: Given the line
1,"Smith, John",30, how many fields does a human see, and how many would a plain comma-split report? Why do they differ?
strtok and strtok_r: the destructive tokenizerDefinition. strtok walks a string and returns one token at a time, treating any of the delimiter characters as a separator. It works by overwriting each delimiter with a \0 so the returned pointer looks like a normal C string, and by remembering where it stopped so the next call can continue.
How it works internally — memory view. Starting with "Alice,30,Paris":
before: A l i c e , 3 0 , P a r i s \0
after: A l i c e \0 3 0 \0 P a r i s \0
^ ^
comma -> \0 comma -> \0
returns: "Alice" then "30" then "Paris"
Two consequences you must remember:
char *s = "a,b"; then strtok(s, ...) is undefined behaviour — the literal is read-only). Use a writable array or a copy.strtok keeps that "where I stopped" position in a single hidden static variable, so it is not re-entrant and not thread-safe, and you cannot interleave two tokenizations. The fix is strtok_r (POSIX) or strtok_s (C11 Annex K / Windows), which keep the position in a char *saveptr you own:char *save = NULL;
char *tok = strtok_r(line, ",", &save);
while (tok) { tok = strtok_r(NULL, ",", &save); }
A subtle pitfall: strtok treats runs of delimiters as one separator and skips leading/trailing delimiters, so it silently drops empty fields. For "a,,b" it returns only "a" and "b" — the empty middle field vanishes. For CSV, where empty fields are meaningful, this is a real bug.
When to use it: quick parsing of clean, unquoted, no-empty-field data, or as a learning tool. When NOT to: any data that may be quoted, may contain empty fields, or is shared across threads.
Knowledge check (predict the output): You run
strtok_rwith delimiter","over the buffer"x,,y". How many tokens come back, and what are they? Is that what a CSV reader should produce?
Definition. A CSV field may be enclosed in double quotes. Inside the quotes, a comma and a newline are ordinary data. A literal double-quote inside a quoted field is written as two double-quotes (""), which represent one " in the value.
Structure of one quoted field:
"He said ""hi"" to me"
^ ^^ ^^ ^
open esc esc close
value -> He said "hi" to me
Rules a correct reader follows: a " right after a field boundary starts a quoted field; inside it everything is literal until the next "; if that " is immediately followed by another ", it is an escaped quote (emit one ", stay inside); otherwise it closes the field, and the next character should be a delimiter or end-of-line.
When you need this: any file produced by Excel, Google Sheets, a database export, or another standards-following tool. When you can skip it: strictly internal data you generated yourself and guarantee never contains commas, quotes, or newlines in a field.
Because a quoted field may contain a newline, a single record can span several physical lines. That means "read one line, parse it" is only safe for unquoted data. A fully correct reader counts quotes: if a line ends while still inside an open quote, it must read and append the next line before parsing the record.
physical lines: logical record:
1,"line one -> field0 = 1
still same field" field1 = "line one\nstill same field"
\n vs \r\nfgets keeps the trailing newline. On Unix that is \n; on files created on Windows it is \r\n (carriage return + line feed). If you strip only \n, a stray \r clings to the end of the last field and quietly corrupts comparisons ("Paris\r" != "Paris"). Always strip both:
line[strcspn(line, "\r\n")] = '\0'; /* truncate at first CR or LF */
Knowledge check (find-the-bug): A program compares the last CSV field with
strcmp(field, "yes")and it never matches on a file emailed from a Windows user, even though the field clearly readsyes. What is almost certainly stuck on the end offield, and which one line fixes it?
#include <string.h>
/* Re-entrant tokenizer: you own the save pointer. */
char *strtok_r(char *str, const char *delim, char **saveptr);
/* - First call: pass the buffer in `str`.
* - Later calls: pass NULL in `str`, same `saveptr`.
* - Returns the next token, or NULL when done.
* - DESTRUCTIVE: writes '\0' over each delimiter in `str`. */
/* Length of the prefix of s that contains NONE of the chars in reject.
Handy for trimming line endings. */
size_t strcspn(const char *s, const char *reject);
line[strcspn(line, "\r\n")] = '\0';
/* Bounded copy of a field into a fixed buffer. snprintf NUL-terminates
and never writes past the size, unlike strcpy. */
int snprintf(char *dst, size_t size, const char *fmt, ...);
snprintf(out, outsz, "%s", field);
Key rules: the buffer you tokenize must be writable (an array or a copy, never a string literal); you must keep one saveptr per independent tokenization; and you must check every return value (fopen, fgets, allocations) before using it.
CSV (Comma-Separated Values) seems easy: split each line on commas, and you have your fields.
A quick approach like strtok(line, ",") does work for plain input. But it breaks the moment the data gets realistic.
It fails on:
"Smith, John", where the comma is part of the data, not a separator.libcsv. It already handles the tricky cases for you."…,…" quoting is about 50 lines of C — and a great exercise.#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#define MAX_LINE 1024
#define MAX_FIELDS 64
/*
* Parse ONE CSV record that is already a NUL-terminated line (line endings
* stripped). Handles double-quoted fields, escaped quotes (""), and commas
* inside quotes. Embedded newlines across physical lines are out of scope
* here (see the lesson notes); this parses a single logical line.
*
* Writes pointers to each field's start into fields[]; the parse is done
* IN PLACE by shifting characters and inserting '\0', so `line` is mutated.
* Returns the number of fields, or -1 if there are too many for `fields`.
*/
static int parse_csv_line(char *line, char *fields[], int max_fields)
{
int count = 0;
char *read = line; /* where we read from */
char *write = line; /* where we write the cleaned field */
while (1) {
if (count >= max_fields)
return -1; /* refuse to overflow fields[] */
fields[count++] = write; /* this field starts here */
bool in_quotes = false;
bool more_fields = false; /* did we stop on a comma? */
while (*read) {
char c = *read;
if (in_quotes) {
if (c == '"') {
if (read[1] == '"') { /* "" -> one literal quote */
*write++ = '"';
read += 2;
continue;
}
in_quotes = false; /* closing quote */
read++;
continue;
}
*write++ = c; /* literal char inside quotes */
read++;
} else {
if (c == '"') { /* opening quote */
in_quotes = true;
read++;
} else if (c == ',') { /* field boundary */
read++; /* consume the comma NOW, before */
more_fields = true; /* the '\0' below overwrites it */
break;
} else {
*write++ = c;
read++;
}
}
}
*write++ = '\0'; /* terminate this field */
if (more_fields) /* another field follows */
continue;
break; /* end of line */
}
return count;
}
int main(void)
{
char line[MAX_LINE];
char *fields[MAX_FIELDS];
/* Read CSV records from stdin so the example is self-contained. */
while (fgets(line, sizeof line, stdin)) {
line[strcspn(line, "\r\n")] = '\0'; /* strip \n and \r\n */
int n = parse_csv_line(line, fields, MAX_FIELDS);
if (n < 0) {
fprintf(stderr, "too many fields, skipping line\n");
continue;
}
for (int i = 0; i < n; i++)
printf("[%d] '%s'\n", i, fields[i]);
printf("--- %d field(s) ---\n", n);
}
return 0;
}
What it does. It reads CSV lines from standard input, strips the line ending, and parses each line with a small state machine that tracks whether it is currently inside double quotes. It compacts each field in place (turning "" into ", dropping the surrounding quotes) and prints every field with its index.
Expected output. Given the input line 1,"Smith, John","He said ""hi""" the program prints:
[0] '1'
[1] 'Smith, John'
[2] 'He said "hi"'
--- 3 field(s) ---
Edge cases handled: commas inside quotes stay in the field; "" becomes a single "; a trailing comma yields a final empty field (a,b, -> three fields, last one empty); too many fields are rejected rather than overflowing fields[]. Out of scope on purpose: a quoted field that contains a real newline spanning two physical lines (you would need to read and append the next line first), and malformed input such as an unterminated quote (this parser just consumes to end of line).
We trace parse_csv_line on the buffer 1,"Smith, John" (a two-field line) to see how the in-place compaction works. read scans forward; write lays down the cleaned bytes; they share the same buffer and write never gets ahead of read.
| Step | *read |
state | action | buffer so far (write side) |
|---|---|---|---|---|
| start field 0 | 1 |
unquoted | copy 1 |
1 |
, |
unquoted | boundary: consume comma, set more_fields, break |
1 |
|
| terminate | — | — | write \0; more_fields -> next field |
1\0 |
| start field 1 | " |
unquoted | opening quote, skip | 1\0 |
S |
quoted | copy | 1\0S |
|
m..h |
quoted | copy each | 1\0Smith |
|
, |
quoted | comma is literal, copy | 1\0Smith, |
|
|
quoted | copy space | 1\0Smith, |
|
John |
quoted | copy each | 1\0Smith, John |
|
" |
quoted | read[1] is \0, not " -> closing quote, skip |
1\0Smith, John |
|
| terminate | \0 |
— | inner loop ends at \0; write \0, more_fields false -> break |
1\0Smith, John\0 |
Now fields[0] points at 1, fields[1] points at Smith, John, and the function returns 2. Notice the comma inside the quotes survived as data, and the surrounding quotes were dropped.
Why the in-place trick is safe: every cleaned field is the same length or shorter than the raw text (we only ever remove quote characters, never add), so write always trails read. That guarantees we never clobber a byte we still need to read.
The main loop ties it together: fgets reads at most sizeof line - 1 bytes (no overflow), strcspn(line, "\r\n") returns the index of the first carriage return or line feed (or end of string), and assigning '\0' there truncates the newline. Then each parsed field is printed by index.
/* WRONG: literals are read-only; strtok tries to write into it */
char *line = "Alice,30,Paris";
char *tok = strtok(line, ","); /* undefined behaviour, often a crash */
Why wrong: strtok overwrites delimiters with \0, but a string literal lives in read-only memory. Fix: use a writable array (or a copy):
char line[] = "Alice,30,Paris"; /* writable array */
char *save = NULL;
char *tok = strtok_r(line, ",", &save);
Recognize it: a segfault on the first token, only with literal input.
strtok/* WRONG for CSV: "a,,b" loses the empty middle field */
for (char *t = strtok(line, ","); t; t = strtok(NULL, ",")) ...
Why wrong: strtok collapses consecutive delimiters and skips empty fields, so a column count is off and later columns shift. Fix: use a parser that treats each comma as a boundary (like the example above), which preserves empty fields.
strcpy into a fixed bufferchar name[16];
strcpy(name, field); /* WRONG: a long field overflows `name` */
Why wrong: a CSV field can be any length; strcpy writes until the source \0, smashing the stack. Fix: bound it.
snprintf(name, sizeof name, "%s", field); /* never writes past `name` */
Recognize it: mysterious corruption or crashes that depend on input length — a classic buffer overflow.
\nline[strlen(line) - 1] = '\0'; /* WRONG: leaves \r on Windows files,
and crashes on an empty line */
Fix: line[strcspn(line, "\r\n")] = '\0'; handles \n, \r\n, and lines with no newline at all.
Compiler errors / warnings
strtok_r: it is POSIX, not plain ISO C. Compile with -D_POSIX_C_SOURCE=200809L (or -std=gnu11), or use the portable hand parser shown here.#include <string.h>, so the compiler assumes strtok/strcspn return int.Runtime errors
fields[i] for an i the parser never filled, or you read past the count it returned. Always loop 0 .. n-1.Logic errors
[%d] '%s'). If a field shows a comma you expected to be a separator, your quote handling is off; if an empty field disappeared, you are using strtok.\r ghost: if strcmp never matches a value that looks correct, print the field with visible bounds like printf("'%s'\n", f) — a stray 'Paris\r' will show the issue, or dump bytes with od -c.Questions to ask when it does not work
fgets bound vs longest line)?\r and \n?in_quotes state at each comma.CSV parsing is mostly an exercise in safe input handling. The risks specific to this topic:
fgets(line, sizeof line, fp) — never gets (removed from C11) and never an unbounded scanf("%s"). Decide what happens if a line is longer than your buffer: fgets will split it, so you may get a partial record. Detect it (no \n found) if correctness matters.snprintf(dst, sizeof dst, "%s", field) or memcpy with an explicit, checked length — never strcpy/strcat into a fixed array.strtok) and the in-place parser both write into the buffer. Passing a string literal is undefined behaviour; pass a writable array.fields[0 .. n-1]. Reading an unfilled slot is uninitialized-pointer use. The example refuses (return -1) rather than overflowing fields[] — bound your arrays the same way.[0, length]. An off-by-one when inserting \0 can drop the last field or write one byte past the array.\0. The parser guarantees this by writing \0 at each boundary; if you build fields manually, do the same.A quick way to catch these while learning: compile with -fsanitize=address,undefined -Wall -Wextra and run on messy sample files. The sanitizers report overflows and read-only writes immediately.
COPY ... FROM, LOAD DATA INFILE), and BI tools all exchange CSV. A robust reader is the front door of an import pipeline.Beginner habits:
fgets) and copy fields with a bounded function (snprintf).\r and \n.fopen, fgets, allocations.Advanced habits:
, vs ; vs tab), quote char, and whether the first row is a header. Do not hard-code assumptions silently.eval/execute a field value.fclose the file) and run under ASan/UBSan in CI.Objective: count the comma-separated fields in a clean line.
Requirements: write int count_fields(const char *line) returning the number of fields, defined as the number of commas plus one (an empty line has one empty field).
Example: "a,b,c" -> 3; "a,,b" -> 3; "" -> 1.
Constraints: do not modify line; do not use strtok (it would miss empty fields). Hint: loop once, increment a counter on each ,. Concepts: string traversal, why empty fields matter.
Objective: safely remove a trailing \n or \r\n.
Requirements: write void chomp(char *s) that truncates s at the first \r or \n. It must do nothing harmful on an empty string.
Example: "Paris\r\n" -> "Paris"; "Paris" -> "Paris"; "" -> "".
Constraints: one pass, no out-of-bounds access. Hint: strcspn(s, "\r\n") gives the index to terminate at. Concepts: strcspn, NUL-termination.
Objective: copy field idx from a simple line into a bounded buffer.
Requirements: write int get_field(const char *line, int idx, char *out, int outsz) that copies the 0-based field into out (always NUL-terminated, never exceeding outsz), returns 0 on success, -1 if idx is out of range.
Example: get_field("a,bb,ccc", 1, out, sizeof out) -> out == "bb", returns 0.
Constraints: never write past outsz; do not modify line. Hint: walk commas to find the start/end of field idx, then copy with a length check (or snprintf with "%.*s"). Concepts: bounded copy, off-by-one care.
Objective: count fields correctly when fields may be quoted.
Requirements: extend Beginner 1 so commas inside double quotes do not count as separators; "" inside quotes is an escaped quote, not a closer.
Example: 1,"Smith, John",30 -> 3; "a""b",c -> 2.
Constraints: single pass, track an in_quotes flag. Hint: reuse the state-machine logic from the lesson example but only count, do not copy. Concepts: state machine, quoting/escaping.
Objective: read a full logical CSV record that may span several physical lines because a quoted field contains a newline.
Requirements: write a function that, given a FILE *, returns the next complete record as one string (or NULL at EOF). Keep reading and appending lines while the number of unescaped quotes seen so far is odd (meaning a quote is still open). Then parse it with your quote-aware parser.
Example input:
1,"line one
still same field",ok
Expected: one record with three fields, field 1 = line one\nstill same field.
Constraints: bounded buffers throughout; handle EOF mid-quote gracefully (treat as end of record). Hint: count quotes per line to decide whether to keep reading; remember "" is two quotes (even count, stays balanced). Concepts: state across lines, dynamic or bounded growth, RFC-4180-style quoting.
fgets) and C string handling.strtok(line, ",") works only for clean data. It is destructive (writes \0 over delimiters, so never on a string literal), not re-entrant (prefer strtok_r), and it silently drops empty fields — wrong for CSV." may contain commas and newlines; a literal quote is written "". Correct parsing needs a small state machine that tracks whether it is inside quotes.strtok_r(str, delim, &saveptr), line[strcspn(line, "\r\n")] = '\0' to strip endings, and snprintf(dst, sizeof dst, "%s", field) for bounded field copies.strtok, strcpy into a fixed buffer (overflow), and stripping only \n (leaving \r).libcsv rather than hand-rolling.