Embercode
✕
Character Arrays and StringsLesson 6 of 16

40 min read

CSV splitting

Why this matters

A CSV file is a plain text file where each line is one row of a table and the values are separated by commas. CSV stands for comma-separated values. Spreadsheets export to CSV, shops export orders to CSV, and many data sets you will download for practice are CSV. A program that reads such a file must turn each line into a list of values. That step is called splitting.

Splitting looks easy. Find each comma, cut the text there, and you have the values. This works for simple lines and fails for real ones. A name like Smith, John contains a comma, and the comma is part of the name, not a separator. A cell can be empty. A cell can contain a quote character. This lesson builds a splitter step by step, shows the cases where the simple version gives wrong answers, and ends with the rules a correct splitter must follow.

You will need the string tools from the earlier lesson on std::string: size(), operator[], push_back and clear. You will also use std::vector, which is a list that grows as you add items.

The idea: a line is a list of fields

Each piece between two commas is called a field. A line is made of fields joined by commas. The comma is the delimiter, the character that marks where one field ends and the next begins.

Take this line:

apple,banana,cherry

It has three fields: apple, banana and cherry. It has two commas, and the number of fields is always one more than the number of commas. Keep that rule in mind, because it explains many of the bugs in this lesson.

To split a line, read it one character at a time. Keep a string called current that holds the field you are building. When you see a comma, save current as a finished field and start a new empty one. When you see any other character, add it to current. When the line ends, save current once more, because the last field has no comma after it.

Example 1: the simple split

Here is the plan written as C++. The function returns a vector<string>, which holds one string per field.

#include <iostream>
#include <string>
#include <vector>
using namespace std;
 
vector<string> naiveSplit(const string& line) {
    vector<string> fields;
    string current;
    for (char c : line) {
        if (c == ',') {
            fields.push_back(current);
            current.clear();
        } else {
            current += c;
        }
    }
    fields.push_back(current);
    return fields;
}

The loop for (char c : line) visits each character in order. current += c adds one character to the end of the string. fields.push_back(current) copies the finished field into the list. current.clear() empties the string so the next field starts fresh.

The parameter is written const string&. The & means the function receives the original string instead of a copy. The word const means the function will not change it. This is the usual way to pass a string you only want to read.

Now run it on a few lines to see what it does.

void show(const string& line) {
    vector<string> f = naiveSplit(line);
    cout << "line: " << line << "\n";
    cout << "count: " << f.size() << "\n";
    for (size_t i = 0; i < f.size(); i++) {
        cout << "  [" << i << "] <" << f[i] << ">\n";
    }
}
 
int main() {
    show("apple,banana,cherry");
    show("a,,c,");
    show("\"Smith, John\",42,London");
    show("");
}

The angle brackets around each value make empty and spaced values visible. Compile and run it with the commands below. The -Wall flag asks the compiler to report common mistakes, and -std=c++20 selects the C++ 2020 rules.

g++ -std=c++20 -Wall -o csv1 csv1.cpp
./csv1

The output on g++ 15.2 is:

line: apple,banana,cherry
count: 3
  [0] <apple>
  [1] <banana>
  [2] <cherry>
line: a,,c,
count: 4
  [0] <a>
  [1] <>
  [2] <c>
  [3] <>
line: "Smith, John",42,London
count: 4
  [0] <"Smith>
  [1] < John">
  [2] <42>
  [3] <London>
line: 
count: 1
  [0] <>

The first line works. The second line shows two rules. A pair of commas with nothing between them gives an empty field, so a,,c, has four fields, and the last one is empty because a comma comes after c. The empty line gives one field, which is also empty. A line with no commas is one field.

The third line is wrong. The input has two real fields, "Smith, John" and 42, plus London, so three fields. The naive split returns four, and it cut the name in half. The next example shows how to fix this.

Why a naive split breaks: quotes

CSV uses double quotes to protect a field that contains a comma. The rules, from the common CSV standard (RFC 4180), are:

  • A field that contains a comma, a quote or a line break should be wrapped in double quotes.
  • Inside a quoted field, a double quote is written as two double quotes. The text He said "hi" is written as "He said ""hi""" in the file.
  • The quotes around a field are not part of its value.

A splitter that follows these rules needs to remember one thing: whether it is currently inside quotes. This is a state, a value that tells the code what situation it is in. The state here has two values: inside quotes or outside quotes. The same comma means different things in the two states. Outside quotes, a comma ends a field. Inside quotes, a comma is ordinary text.

Example 2: a splitter that understands quotes

Here is the full function. The variable inQuotes is the state.

vector<string> csvSplit(const string& line) {
    vector<string> fields;
    string current;
    bool inQuotes = false;
    for (size_t i = 0; i < line.size(); i++) {
        char c = line[i];
        if (inQuotes) {
            if (c == '"') {
                if (i + 1 < line.size() && line[i + 1] == '"') {
                    current += '"';
                    i++;
                } else {
                    inQuotes = false;
                }
            } else {
                current += c;
            }
        } else if (c == '"') {
            inQuotes = true;
        } else if (c == ',') {
            fields.push_back(current);
            current.clear();
        } else {
            current += c;
        }
    }
    fields.push_back(current);
    return fields;
}

Read the inside-quotes branch first. A quote character " either starts an escaped quote or ends the quoted part.

If the next character is also a quote, the two quotes stand for one quote character in the value. The code adds one " to current, then skips the second quote with i++. Without that skip, the loop would read the second quote again on the next pass and end the quoted part too early.

If the next character is not a quote, this quote closes the quoted part, so inQuotes becomes false. The quote itself is not added to current. This is why the quotes are missing from the output values.

Any other character inside quotes, including a comma, is added to current as plain text.

The branch outside quotes handles the normal case. A quote starts the quoted part. A comma ends the field. Any other character is added to the field.

Now run it on tricky lines.

int main() {
    show("\"Smith, John\",42,London");
    show("\"He said \"\"hi\"\"\",,\"\",end,");
    show("a,,c,");
    show("plain,fields");
}

The output is:

line: "Smith, John",42,London
count: 3
  [0] <Smith, John>
  [1] <42>
  [2] <London>
line: "He said ""hi""",,"",end,
count: 5
  [0] <He said "hi">
  [1] <>
  [2] <>
  [3] <end>
  [4] <>
line: a,,c,
count: 4
  [0] <a>
  [1] <>
  [2] <c>
  [3] <>
line: plain,fields
count: 2
  [0] <plain>
  [1] <fields>

The name Smith, John now comes out as one field, with the comma kept. The second line shows the two rules working together. "He said ""hi""" becomes He said "hi". The field "" is an empty quoted field, so it becomes an empty string, the same as a field with nothing in it. The last comma gives the final empty field.

The function still has limits. It treats a quote anywhere in a field as the start of quoted text, which is more lenient than the standard. It also reads one line at a time, so a quoted field that contains a line break will be cut at the break. Handling that needs a reader that keeps reading lines while it is inside quotes. The lesson stops at one line because most CSV work reads one line at a time, and the state idea is the same.

Example 3: reading a file with a header

Real files have a first line of column names, called the header, and each line ends with a line break. The program below writes a small file on Windows-style line endings, then reads and splits it. The file name is data.csv.

#include <fstream>
#include <iostream>
#include <string>
using namespace std;
 
int main() {
    ofstream out("data.csv");
    out << "name,age\r\nAda,36\r\n";
    out.close();
 
    ifstream in("data.csv");
    string line;
    getline(in, line);
    cout << "header length: " << line.size() << "\n";
    cout << "last char code: " << (int)line.back() << "\n";
}

std::getline reads characters up to the newline, \n, and removes the \n. A Windows file ends each line with \r\n, so the \r (carriage return, character code 13) stays at the end of the line. The output shows it:

header length: 9
last char code: 13

The header name,age has 8 characters, so the length 9 means the carriage return is still there. A splitter that runs on this line will return age\r as the last field. The fix is to remove a trailing \r before splitting. The next section shows the bug this causes.

Example 4: putting it together

The last program reads the whole file, skips the header, removes the carriage return and splits each row. It also converts the age field to a number. The number conversion uses stoi, which turns text such as 36 into the integer 36.

#include <fstream>
#include <iostream>
#include <string>
#include <vector>
using namespace std;
 
vector<string> csvSplit(const string& line) {
    vector<string> fields;
    string current;
    bool inQuotes = false;
    for (size_t i = 0; i < line.size(); i++) {
        char c = line[i];
        if (inQuotes) {
            if (c == '"') {
                if (i + 1 < line.size() && line[i + 1] == '"') {
                    current += '"';
                    i++;
                } else {
                    inQuotes = false;
                }
            } else {
                current += c;
            }
        } else if (c == '"') {
            inQuotes = true;
        } else if (c == ',') {
            fields.push_back(current);
            current.clear();
        } else {
            current += c;
        }
    }
    fields.push_back(current);
    return fields;
}
 
int main() {
    ofstream out("people.csv");
    out << "name,age,city\n";
    out << "\"Smith, John\",42,London\n";
    out << "Ada,36,\"\"\n";
    out.close();
 
    ifstream in("people.csv");
    string line;
    getline(in, line);
    if (!line.empty() && line.back() == '\r') {
        line.pop_back();
    }
    cout << "header: " << line << "\n";
 
    while (getline(in, line)) {
        if (!line.empty() && line.back() == '\r') {
            line.pop_back();
        }
        vector<string> f = csvSplit(line);
        if (f.size() != 3) {
            cout << "skipped a row with " << f.size() << " fields\n";
            continue;
        }
        int age = stoi(f[1]);
        cout << f[0] << " is " << age << " and lives in <" << f[2] << ">\n";
    }
}

The while (getline(in, line)) loop keeps going until the file has no more lines. getline returns the stream, and a stream used as a condition is true while the last read worked. The header is read once before the loop, so it is not treated as data.

The line if (!line.empty() && line.back() == '\r') removes the carriage return if it is there. pop_back removes the last character. Checking empty() first matters, because calling back() on an empty string is an error.

The check f.size() != 3 protects the rest of the code from rows with the wrong number of columns. Without it, f[2] would be read past the end of a short row.

Running the program prints:

header: name,age,city
Smith, John is 42 and lives in <London>
Ada is 36 and lives in <>

The city of the second row is empty, because the field was written as "". The program treats it as a valid empty value.

Loose and broken input

Real files are not always tidy. Two kinds of input cause trouble, and it helps to see what the splitter does with each one before you decide how to handle it.

The first kind is spaces after the commas. Many people write a, b, c by hand. The splitter keeps the spaces, because a space is an ordinary character to it. The output shows the result:

line: a, b, c
count: 3
  [0] <a>
  [1] < b>
  [2] < c>

The second field is b, with a space in front. The space is not part of the data you meant, so most programs remove it. A small helper function does this. It finds the first and last characters that are not spaces or tabs, then keeps only the text between them.

string trim(const string& s) {
    size_t first = s.find_first_not_of(" \t");
    if (first == string::npos) {
        return "";
    }
    size_t last = s.find_last_not_of(" \t");
    return s.substr(first, last - first + 1);
}

The find_first_not_of function returns the position of the first character that is not in the set " \t", which is a space or a tab. If every character is a space or tab, it returns string::npos, and the function returns an empty string. The call s.substr(first, last - first + 1) takes the characters from first through last, inclusive. Running trim on " b ", "c", " " and "" prints <b>, <c>, <> and <>. Apply trim to each field after splitting, not to the whole line, so that a space inside a quoted field stays as it is.

The second kind is broken quotes. A quote in the middle of an unquoted field, or a quote that is never closed, confuses the state. The splitter in Example 2 does not reject these lines. It guesses. The output shows what it guesses:

line: ab"c,d
count: 1
  [0] <abc,d>
line: "unclosed,d
count: 1
  [0] <unclosed,d>

In ab"c,d, the quote switches the splitter into quoted mode in the middle of the field. The comma after it is then treated as text, so the two fields merge into one, abc,d. In "unclosed,d, the quote is never closed, so everything after it, including the comma, is part of one field. The splitter does not stop with an error. If your program needs strict input, check each row's field count and quote count, and report a row that does not fit, as the skipped a row check in Example 4 does.

Common mistakes

Splitting inside quoted text. The most common bug is a plain find(',') loop or the naive splitter used on files that contain quoted commas. The result is more fields than the header has, and every value after the bad field is shifted one column to the left. The fix is the state-based function from Example 2.

Forgetting the last field. A beginner often adds a field only when a comma is found. Then a,b gives one field, a, and the b is lost. Always add the text in current after the loop ends. The naive split above does this on its final line.

Expecting one field per line. A line with no commas still has one field. A line with n commas has n + 1 fields. If a row has a different count, the file or the split is wrong.

Leaving a carriage return in the value. The output below comes from comparing a header read from a Windows file:

string line = "name,age\r";
cout << (line == "name,age") << "\n";

It prints:

0

The comparison is false because the string still has the \r at its end. The value Ada\r is also not equal to Ada. Remove the \r first, as Example 4 does.

Converting an empty field to a number. Calling stoi on an empty string throws an exception, which is an error signal that stops the normal flow of the program. The code below does not catch it:

#include <iostream>
#include <string>
using namespace std;
int main() {
    string age = "";
    int n = stoi(age);
    cout << n << "\n";
}

The compiler accepts it. When the program runs, it stops with this message on g++ 15.2:

terminate called after throwing an instance of 'std::invalid_argument'
  what():  stoi

Check the field before converting it, or wrap the call in try and catch (a try block runs code that may fail, and catch handles the error), as the exercises below show.

Indexing past the end of the row. f[2] on a vector with two items is not checked. On g++ 15.2 on Ubuntu, the standard library checks the index and stops with a message that ends like this. Other compilers may not check, and then the read returns a random value:

Assertion '__n < this->size()' failed.

Check f.size() before using an index, as Example 4 does. f.at(2) is an alternative. It throws std::out_of_range instead of stopping silently, which makes the error easier to find.

Practice

Exercise 1. Write a function int countFields(const string& line) that returns the number of fields, using the rule that a line has one more field than commas. It must not count commas that are inside quotes. Test it on a,b,c and on "x, y",z.

Show the answer

Count the commas outside quotes and add one. Use the same state idea as csvSplit. For a,b,c the result is 3. For "x, y",z the result is 2, because the comma inside the quotes does not count. The function can simply return csvSplit(line).size(), which is the shortest correct answer:

int countFields(const string& line) {
    return (int)csvSplit(line).size();
}

Exercise 2. The line "a""b",c should produce the fields a"b and c. Trace what csvSplit does with the first four characters after the opening quote, step by step, and say what each step adds to current.

Show the answer

After the opening quote, inQuotes is true. The character a is added, so current is a. The next two characters are two quotes, which the code reads as one escaped quote. It adds " and skips the second quote, so current is a". The character b is added, so current is a"b. The next quote is not followed by another quote, so it closes the quoted part. The comma then ends the field, and a"b is saved.

Exercise 3. Write a function bool isValidRow(const vector<string>& fields, size_t expected) that returns true only when the row has the expected number of fields and the first field is not empty. Call it on the fields of ,b,c and on a,b,c with expected set to 3.

Show the answer

bool isValidRow(const vector<string>& fields, size_t expected) {
    return fields.size() == expected && !fields.empty() && !fields[0].empty();
}

The && operator makes each test run only when the tests before it are true, so fields[0] is never read from an empty list. The row ,b,c has three fields, but its first field is empty, so the function returns false. The row a,b,c returns true when expected is 3.

Exercise 4. Write a function int safeAge(const string& text, int fallback) that returns the number in text when it is a valid integer, and fallback otherwise. Use try and catch around stoi, and catch std::exception.

Show the answer

int safeAge(const string& text, int fallback) {
    try {
        return stoi(text);
    } catch (const exception&) {
        return fallback;
    }
}

When text is empty, stoi throws std::invalid_argument, which is a kind of std::exception, so the catch runs and returns fallback. A value that is too large for an int throws std::out_of_range, which the same catch also handles. The const exception& form catches the error without copying it. The name is not needed, so it is left out.

Exercise 5. A row is Ada,"",36. Explain why a splitter that only looks for commas returns the right number of fields here but the wrong meaning for the second field, and say which code path in csvSplit makes the difference.

Show the answer

A comma-only splitter returns three fields: Ada, "" with the quotes still in it, and 36. The count is right, but the second field holds two quote characters, not an empty value. The quote is kept as text. In csvSplit, the quote character outside quotes sets inQuotes to true, and the next quote closes it. Nothing is added to current, so the field is saved as an empty string. The state branch outside quotes is the one that makes the difference.

Summary

  • A CSV line is a list of fields separated by commas. A line with n separators has n + 1 fields.
  • A plain split on commas breaks when a field contains a comma inside quotes.
  • A correct splitter keeps a state flag, inQuotes, and treats a comma as a separator only when the flag is false.
  • Inside quotes, two quote characters in a row stand for one quote in the value.
  • Remove a trailing \r from Windows line endings before splitting, and check the field count before reading a column.
  • Convert a field to a number only after checking it, or inside a try block, because stoi throws on empty or invalid text.
Practice this topic: coding problems for Character Arrays and Strings →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Module assignment: Text tools in C++