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.

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":
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) })")sw_vers -productVersion; swift --version 2>&1 | head -n 1; sysctl -n machdep.cpu.brand_string; sysctl -n hw.ncpu26.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
10swift/.build/release/csvlab crlfcount 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" }.countis 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.countis 15 andtext.utf8.countis 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:
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:
swift/.build/release/csvlab people data/people.csv["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:
swift/.build/release/csvlab lines data/people.csvline 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 linesIt 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:
swift/.build/release/csvlab zips data/zips.csvtext: 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.0For 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:
// 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:
swift/.build/release/csvlab swiftcsv data/people.csvheader: ["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:
swift/.build/release/csvlab swiftcsv-bom data/bom.csvheader: ["\"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:
swift/.build/release/csvlab swiftcsv-ragged data/ragged2.csvrows: 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:
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()))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.csvhand 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=197MBThe 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.


