Skip to content
datahacks.dev
CSV

Reading a CSV in Swift on macOS and the CRLF Line Trap

Swift has no CSV parser in Foundation, and its String treats CRLF as one Character, so splitting on a newline fails. Parse bytes or use SwiftCSV.

9 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar, with the GNU Bash logo.

Apple's Foundation can read a file, split a string, parse a number and decode JSON. It cannot parse a CSV. You either write the parser or add a package, and the first thing anyone writes is the split that Swift makes subtly wrong. The reason is a design decision in Swift's String that is correct for human text and a trap for line endings. This article shows the trap on three lines of code, a parser over bytes that avoids it, what a package does with the series' test files, and timings from a Mac. The comparison with other languages is in the guide.

The CRLF trap

A Swift Character is an extended grapheme cluster: what a reader would call one character. Unicode says that a carriage return followed by a line feed is one such cluster, so in Swift "\r\n" is one Character. That is right for text and wrong for splitting a CRLF file on "\n":

main.swiftSwift
case "crlf":
    let text = "id,qty\r\n1,3\r\n2,5\r\n"
    print("count of Character \"\\r\\n\": \(text.filter { $0 == "\r\n" }.count)")
    print("split(separator: \"\\n\"): \(text.split(separator: "\n").count) piece(s)")
    print("split(separator: \"\\r\\n\"): \(text.split(separator: "\r\n").count) piece(s)")
    print("components(separatedBy: \"\\n\"): \(text.components(separatedBy: "\n").count) pieces")
    print("unicodeScalars count: \(text.unicodeScalars.count), utf8 count: \(text.utf8.count), characters: \(text.count)")
    print("naive lines then split(\",\"): \(text.split(separator: "\n").map { $0.split(separator: ",").map(String.init) })")
Shell
sw_vers -productVersion; swift --version 2>&1 | head -n 1; sysctl -n machdep.cpu.brand_string; sysctl -n hw.ncpu
Text
26.6.2
swift-driver version: 1.148.6 Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)
Apple M1 Max
10
Shell
swift/.build/release/csvlab crlf
Text
count of Character "\r\n": 3
split(separator: "\n"): 1 piece(s)
split(separator: "\r\n"): 3 piece(s)
components(separatedBy: "\n"): 4 pieces
unicodeScalars count: 18, utf8 count: 18, characters: 15
naive lines then split(","): [["id", "qty\r\n1", "3\r\n2", "5\r\n"]]

The string is id,qty \r\n 1,3 \r\n 2,5 \r\n. Look at the numbers:

  • text.filter { $0 == "\r\n" }.count is 3: the line endings are three Characters, each a pair of bytes.

  • text.split(separator: "\n") returns 1 piece. There is no "\n" Character in the string, because each line feed is fused to its carriage return.

  • text.split(separator: "\r\n") returns 3 pieces: correct, for a CRLF file, and wrong for an LF file.

  • text.components(separatedBy: "\n") returns 4 pieces (the last is empty), because the Foundation method works on the underlying UTF-16 units and not on Characters.

  • text.count is 15 and text.utf8.count is 18: three Characters fewer, one for each pair.

So split(separator: "\n") followed by split(separator: ","), the code everyone writes, gave [["id", "qty\r\n1", "3\r\n2", "5\r\n"]] on this file: one line with the commas split and the line endings left inside the values. The same code works on a file with LF endings, which is why it ships. A file from Windows, or from Excel, or from a Python script that forgot newline="", breaks it, and breaks it without an error.

Parse bytes

The way out is to work on the UTF-8 bytes, where \r (13) and \n (10) are separate values and the delimiter and the quote are single bytes:

main.swiftSwift
func parseCSV(_ data: Data) -> [[String]] {
    var rows: [[String]] = []
    var row: [String] = []
    var field: [UInt8] = []
    var inQuotes = false
    var i = data.startIndex
    let end = data.endIndex
    func endField() { row.append(String(decoding: field, as: UTF8.self)); field.removeAll(keepingCapacity: true) }
    while i < end {
        let c = data[i]
        if inQuotes {
            if c == 0x22 {
                let n = data.index(after: i)
                if n < end && data[n] == 0x22 { field.append(0x22); i = n } else { inQuotes = false }
            } else { field.append(c) }
        } else if c == 0x22 && field.isEmpty { inQuotes = true }
        else if c == 0x2C { endField() }
        else if c == 0x0A || c == 0x0D {
            if c == 0x0D, data.index(after: i) < end, data[data.index(after: i)] == 0x0A { i = data.index(after: i) }
            endField(); rows.append(row); row = []
        } else { field.append(c) }
        i = data.index(after: i)
    }
    if !field.isEmpty || !row.isEmpty { endField(); rows.append(row) }
    return rows
}

The function is the same state machine as in the JavaScript article: a flag for being inside quotes, a comma ends a field, a line break (\n, \r or \r\n) ends a record, and "" inside quotes is a quote. It builds each field as a byte array and converts it to a String once. On the people file, which has a quoted comma, doubled quotes and a line break in a field:

Shell
swift/.build/release/csvlab people data/people.csv
Text
["id", "name", "city", "note"]
["1", "Ana Silva", "Lisbon", "likes tea"]
["2", "Smith, Jane", "Boston", "said \"hello\""]
["3", "Bo Chen", "Oslo", "line one\\nline two"]
["4", "Cy Dubois", "Lyon", ""]

The records are right: "Smith, Jane" is one value, said "hello" has its quotes resolved, and the third note holds the line break (printed as \n). It needs no Foundation beyond Data. It does not strip a BOM, validate the width of a record or convert types; each of those is a line you add. URL.lines, the async sequence, is not a substitute:

Shell
swift/.build/release/csvlab lines data/people.csv
Text
line 1: id,name,city,note
line 2: 1,Ana Silva,Lisbon,likes tea
line 3: 2,"Smith, Jane",Boston,"said ""hello"""
line 4: 3,Bo Chen,Oslo,"line one
line 5: line two"
line 6: 4,Cy Dubois,Lyon,
6 lines

It split the file into 6 lines for 4 records: the note with the line break became lines 4 and 5, and "line one is left unterminated. lines is for text, not CSV.

Types

Swift's number initializers parse leading zeros without complaint:

Shell
swift/.build/release/csvlab zips data/zips.csv
Text
text: 02134  Int(...): 2134  Double: 2134.0
text: 90210  Int(...): 90210  Double: 90210.0
text: 00501  Int(...): 501  Double: 501.0
text: 10001  Int(...): 10001  Double: 10001.0

For 02134 the output is Int(...): 2134 Double: 2134.0, and 00501 gave 501. As everywhere, identifiers stay String. When you do convert, use the failable initializers (Int(field), Double(field)) and decide what nil means.

A package: SwiftCSV

If you do not want to maintain a parser, SwiftCSV is the package I would reach for. Its Swift Package Manager manifest:

Package.swiftSwift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "csvlab",
    platforms: [.macOS(.v13)],
    dependencies: [
        .package(url: "https://github.com/swiftcsv/SwiftCSV.git", from: "0.10.0"),
    ],
    targets: [
        .executableTarget(name: "csvlab", dependencies: [.product(name: "SwiftCSV", package: "SwiftCSV")]),
    ]
)

CSV<Named> reads a file with a header into rows that are dictionaries:

Shell
swift/.build/release/csvlab swiftcsv data/people.csv
Text
header: ["id", "name", "city", "note"]
["city=Lisbon", "id=1", "name=Ana Silva", "note=likes tea"]
["city=Boston", "id=2", "name=Smith, Jane", "note=said \"hello\""]
["city=Oslo", "id=3", "name=Bo Chen", "note=line one\\nline two"]
["city=Lyon", "id=4", "name=Cy Dubois", "note="]

The header is ["id", "name", "city", "note"] and each row is a dictionary (I sorted the keys for display). Smith, Jane is whole, the quotes are resolved and the note keeps its line break. It handles the BOM:

Shell
swift/.build/release/csvlab swiftcsv-bom data/bom.csv
Text
header: ["\"id\"", "\"name\""]

The headers printed as "id" and "name" (the quotes are my formatting), with no U+FEFF. And it handles ragged rows by hiding them:

Shell
swift/.build/release/csvlab swiftcsv-ragged data/ragged2.csv
Text
rows: 8 [4, 4, 4, 4, 4, 4, 4, 4]
["city=Lisbon", "id=1", "name=Ana", "note=likes tea"]
["city=Oslo", "id=2", "name=Bo", "note=likes coffee"]
["city=Lyon", "id=3", "name=Cy", "note="]
["city=Rome", "id=4", "name=Di", "note=fine"]
["city=Bern", "id=5", "name=Ed", "note="]
["city=Kyiv", "id=6", "name=Flo", "note=first line"]
["city=", "id=second line of the note", "name=", "note="]
["city=Riga", "id=7", "name=Gus", "note=ok"]

All 8 rows have 4 values. The row 2,Bo,Oslo,likes coffee, no sugar lost its extra field (note=likes coffee). The short row 5,Ed,Bern got an empty note. The record that was split across two lines became a row whose id is second line of the note and whose other fields are empty. Nothing was reported. If row widths matter, check the file with another tool first (qsv validate, as in the shell guide), or use the byte parser and check the width yourself.

Speed on a Mac

Three ways to sum the amount column:

main.swiftSwift
case "sum":
    let t0 = Date()
    let data = try Data(contentsOf: URL(fileURLWithPath: args[2]))
    let rows = parseCSV(data)
    var total = 0.0
    for r in rows.dropFirst() { total += Double(r[3]) ?? 0 }
    print(String(format: "hand parser (whole file) rows=%d total=%.2f time=%.2fs peak_rss=%dMB", rows.count - 1, total, Date().timeIntervalSince(t0), peakRSSMB()))
case "sum-split":
    let t0 = Date()
    let text = try String(contentsOfFile: args[2], encoding: .utf8)
    var total = 0.0, n = 0
    for line in text.split(separator: "\n").dropFirst() { total += Double(line.split(separator: ",", omittingEmptySubsequences: false)[3]) ?? 0; n += 1 }
    print(String(format: "split lines          rows=%d total=%.2f time=%.2fs peak_rss=%dMB", n, total, Date().timeIntervalSince(t0), peakRSSMB()))
case "sum-swiftcsv":
    let t0 = Date()
    let csv = try CSV<Named>(url: URL(fileURLWithPath: args[2]))
    var total = 0.0, n = 0
    try csv.enumerateAsDict { d in total += Double(d["amount"] ?? "") ?? 0; n += 1 }
    print(String(format: "SwiftCSV enumerate   rows=%d total=%.2f time=%.2fs peak_rss=%dMB", n, total, Date().timeIntervalSince(t0), peakRSSMB()))
Shell
swift/.build/release/csvlab sum data/orders-2m.csv
swift/.build/release/csvlab sum-split data/orders-2m.csv
swift/.build/release/csvlab sum-swiftcsv data/orders-200k.csv
Text
hand parser (whole file) rows=2000000 total=160174646.36 time=1.96s peak_rss=599MB
split lines          rows=2000000 total=160174646.36 time=4.88s peak_rss=349MB
SwiftCSV enumerate   rows=200000 total=16018429.85 time=2.07s peak_rss=197MB

The byte parser read all 2,000,000 rows in 1.96 s, with a peak of 599 MB because it holds the whole file as Data and every row as an array of strings. Splitting the text on newlines and commas took 4.88 s at 349 MB, and it is only correct because this file has LF endings and no quotes. SwiftCSV enumerated 200,000 rows in 2.07 s; the same pace on 2,000,000 would be about twenty seconds, which I did not run. All gave the same total, 160174646.36 for the whole file and 16018429.85 for the first 200,000. Swift's speed here is in the same range as Java and Go (about 1 to 2 s); the memory is the part to watch, and the fix is to stream chunks of bytes and parse each, which I did not write.

How to tell it worked

Put a CRLF file through the code and count the records. Print a record with a comma, a quote and a line break. Open a file with a BOM. Keep ZIP codes as strings and check one with a leading zero. Compare the record count with qsv count or DuckDB. And if the code runs on iOS as well as macOS, remember that memory is tighter there, and the whole-file approach above is not the one to ship.

What this was measured on

One Mac, one run per timing, a package last released in 2024, and an Apple Silicon CPU; a Linux server or an Intel Mac would give different absolute numbers. What does not depend on the machine is the grapheme rule: any Swift code that splits on "\n" has the CRLF problem, because the grapheme rule is part of the language and not of the operating system. For the rest of the family, see the guide to CSV in other languages, and for a one-off look at a file, DuckDB.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.