A high-performance CSV parser and writer for Swift
FastCSV is a high-performance CSV parser and writer for Swift. The parser processes large CSV files with minimal memory overhead through streaming and zero-copy techniques. The writer provides Codable round-tripping: read CSV into structs, transform, write back out.
- Decodable support: decode CSV rows directly into Swift structs, only materializing the columns you need
- Column mapping: map CSV headers to struct properties at the call site, no CodingKeys required
- High-performance parsing with zero-copy techniques
- Low memory footprint through chunked streaming: constant memory regardless of file size
- Three API tiers: typed structs via
Decodable, dictionary access by column name, or raw array iteration - Configurable delimiters supporting standard CSV, TSV, and custom formats
- Quote handling with optional optimization for quote-free data
- Error recovery allowing processing to continue despite malformed rows
- UTF-8 BOM detection and automatic removal
- Encodable support: write Swift structs directly to CSV with automatic header derivation
- RFC 4180 quoting: fields containing delimiters, quotes, or newlines are quoted automatically
- Multiple output targets: write to file path, URL, or string
- Row-by-row or batch: streaming writes via
CSVWriteror one-shot via static methods - Round-trip fidelity: read, transform, and write back with full type preservation
Add FastCSV to your project using Swift Package Manager:
dependencies: [
.package(url: "https://github.com/wombat2k/FastCSV.git", from: "1.0.0")
]Define a struct, map the CSV columns to your property names, and iterate:
import FastCSV
struct BusRoute: Decodable {
let route: String
let name: String
let monthTotal: Int
}
var rows = try FastCSV.makeRows(
BusRoute.self,
fromPath: "ridership.csv",
columnMapping: [
"routename": "name",
"MonthTotal": "monthTotal",
]
)
try rows.forEach { route in
print("\(route.name): \(route.monthTotal)")
}Rows are decoded lazily, one at a time, not all at once. Memory stays constant regardless of file size.
The columnMapping parameter maps CSV header names to struct property names:
columnMapping: ["routename": "name"]This means: the CSV column routename fills the struct property name.
You only need entries for columns whose names differ from your properties. Columns that already match (like route above) can be left out. Extra CSV columns are ignored.
If you prefer baking the mapping into the type, Swift's standard CodingKeys works too:
struct BusRoute: Decodable {
let route: String
let name: String
enum CodingKeys: String, CodingKey {
case route
case name = "routename"
}
}
// No columnMapping needed
var rows = try FastCSV.makeRows(BusRoute.self, fromPath: "ridership.csv")Use forEach when you want to iterate through all rows. The callback receives a decoded struct directly:
try rows.forEach { route in
print(route.name)
}Use return to skip rows (not continue, since you're inside a closure). Note that forEach always reads every row in the file, even when individual iterations return early.
Use for-in when you need to stop before the end. Each element is a Result<T, Error>:
for result in rows {
let route = try result.get()
print(route.name)
break
}The Result type also enables per-row error handling:
for result in rows {
switch result {
case .success(let route): print(route.name)
case .failure(let error): print("Skipping: \(error)")
}
}All reading APIs accept file paths, URLs, in-memory Data, or String:
var rows = try FastCSV.makeRows(T.self, fromPath: "/path/to/file.csv")
var rows = try FastCSV.makeRows(T.self, fromURL: url)
var rows = try FastCSV.makeRows(T.self, fromData: csvData)
var rows = try FastCSV.makeRows(T.self, fromString: "name,age\nAlice,30\n")When you don't have a struct or don't know the schema:
// By position
let arrayRows = try FastCSV.makeArrayRows(fromPath: "data.csv")
for row in arrayRows {
let name = try row[0].string
let age = try row[1].int
}
// By column name
let dictRows = try FastCSV.makeDictionaryRows(fromPath: "data.csv")
for row in dictRows {
let name = try row["name"]!.string
}CSVValue provides typed accessors: .string, .int, .double, .float, .bool, .date, .decimal. Use the IfPresent variants (.stringIfPresent, .intIfPresent, etc.) when a field might be empty; they return nil instead of throwing.
Optional struct fields also decode empty CSV values as nil:
struct Person: Decodable {
let name: String
let age: Int? // empty CSV field → nil
}Headers are derived automatically from property names (or CodingKeys):
struct Output: Encodable {
let name: String
let age: Int
}
let people = [Output(name: "Alice", age: 30), Output(name: "Bob", age: 25)]
try FastCSV.writeRows(people, toPath: "output.csv")
let csv = try FastCSV.writeString(people)try FastCSV.writeRows(
[["Alice", "30"], ["Bob", "25"]],
headers: ["name", "age"],
toPath: "output.csv"
)let writer = CSVWriter()
try writer.writeHeaders(["name", "age"])
try writer.writeRow(["Alice", "30"])
if let csv = writer.toString() {
print(csv)
}CSVWriter also accepts a file path or URL in its initializer for streaming to disk.
let tsv = CSVConfig(delimiter: CSVFormat.tsv.delimiter)
var rows = try FastCSV.makeRows(T.self, fromPath: "data.tsv", config: tsv)Supported formats: CSV, TSV, semicolon-separated, or custom field/row/quote delimiters.
Skip quote detection for a ~9% speed boost when your data has no quoted fields:
let config = CSVConfig(assumeNoQuotes: true)For files without a header row:
var rows = try FastCSV.makeRows(
T.self,
fromPath: "data.csv",
hasHeaders: false,
headers: ["name", "age", "city"]
)FastCSV uses CSVDateStrategy for Date parsing and formatting, not DateFormatter. This keeps the package free of ICU on Linux when built against FoundationEssentials. The strategy is honored on both reads (Decodable) and writes (Encodable).
The default is ISO 8601 date-only (yyyy-MM-dd) in GMT. So this just works:
struct Sale: Decodable {
let item: String
let date: Date
}
var rows = try FastCSV.makeRows(Sale.self, fromString: "item,date\nWidget,2026-03-15\n")
try rows.forEach { print($0.date) }For other formats, build a strategy from any ParseableFormatStyle. Most callers will use Date.VerbatimFormatStyle:
let style = Date.VerbatimFormatStyle(
format: "\(month: .twoDigits)/\(day: .twoDigits)/\(year: .defaultDigits)",
locale: .init(identifier: "en_US_POSIX"),
timeZone: .gmt,
calendar: .init(identifier: .gregorian)
)
let config = CSVConfig(dateStrategy: .formatStyle(style))
var rows = try FastCSV.makeRows(Sale.self, fromString: "item,date\nWidget,03/15/2026\n", config: config)The same config works for writing: you'll get 03/15/2026 back out.
Built-ins:
| Strategy | Format |
|---|---|
.iso8601Date (default) |
yyyy-MM-dd in GMT |
.iso8601 |
Full ISO 8601 date-time, e.g. 2026-04-27T12:34:56Z |
.formatStyle(_:) |
Wrap any ParseableFormatStyle<Date, String> |
CSVDateStrategy(format:parse:) |
Provide your own closures |
Pre-1.1 FastCSV exposed dateFormatter: DateFormatter on CSVConfig. Replace with dateStrategy:
// Before
let f = DateFormatter()
f.dateFormat = "MM/dd/yyyy"
let config = CSVConfig(dateFormatter: f)
// After
let style = Date.VerbatimFormatStyle(
format: "\(month: .twoDigits)/\(day: .twoDigits)/\(year: .defaultDigits)",
locale: .init(identifier: "en_US_POSIX"),
timeZone: .gmt,
calendar: .init(identifier: .gregorian)
)
let config = CSVConfig(dateStrategy: .formatStyle(style))The Verbatim format string uses Swift string interpolation rather than the MM/dd/yyyy patterns from DateFormatter. The components map directly: \(month: .twoDigits) for MM, \(day: .twoDigits) for dd, \(year: .defaultDigits) for yyyy, etc. See the Date.VerbatimFormatStyle docs for the full reference.
The Examples directory contains runnable examples using real CTA bus ridership data (40K rows). Each example is a standalone executable target:
cd Examples
swift run Filtering
swift run Aggregation
swift run Writing
swift run RawAccessFastCSV is optimized for high-throughput scenarios. Benchmarked against a 1.4GB NHS prescription dataset (10.3 million rows, 11 columns):
| API | Rows/sec | Notes |
|---|---|---|
| Array iterator (no quotes) | 1.2M | Raw CSVValue access, 6 fields per row |
| Array iterator (standard) | 1.1M | Same access pattern, with quote detection |
| Decodable + columnMapping | 477K | Full struct decoding, 6 typed fields |
Memory stays constant regardless of file size: peak was 8.5MB (0.6% of the 1.4GB file).
The Decodable path is roughly 2x slower than raw array access with equivalent field access. This overhead comes from Swift's Codable protocol machinery (dynamic dispatch, KeyedDecodingContainer, CodingKey resolution per field per row) and is inherent to any Decoder implementation. For maximum throughput on very large files, use the array or dictionary iterators directly.
- macOS: 13.0+
- iOS: 15.0+
- Linux: Swift 6.2+ (Ubuntu 24.04 tested in CI)
- Swift: 6.2+
On Linux, FastCSV builds against FoundationEssentials and avoids pulling in full Foundation. File I/O goes through POSIX (open/read/write/close) rather than FileHandle, which is not part of FoundationEssentials.
MIT License: see LICENSE for details.