Reading CSV Files in Go Java R Rust and C Sharp Compared
Ten languages read the same awkward CSV files: speed on 2,000,000 rows, BOM, bad quotes, ragged rows, leading zeros and defaults that bite, plus a test list.

If you read a CSV from Go, Java, R, Rust, C#, PHP, Ruby, Kotlin or Swift, the library you use does most of the work and the defaults it chose do the rest. Those defaults are where the bugs live: a byte order mark left in a header, an unclosed quote swallowed without a word, a leading zero removed, a decimal point read as a thousands separator, an empty integer turned into zero. The libraries are good, and they disagree with each other on every one of those questions. This guide runs the same small set of awkward files and the same 2,000,000 row file through ten languages and tells you what each did, so you can choose a language's reader with the facts in front of you, or check yours against a portable list of tests. The deeper articles, one per language, are linked in each section.
Everything below was executed. The numbers are measurements from one machine, and the outputs are the ones I got.
The test files
Five small files and one big one, the same for every language. Each exists because some library mishandles it:
id,name,city,note
1,Ana Silva,Lisbon,likes tea
2,"Smith, Jane",Boston,"said ""hello"""
3,Bo Chen,Oslo,"line one
line two"
4,Cy Dubois,Lyon,people.csv has a quoted comma, doubled quotes, a line break inside a quoted field and an empty last field. There are 4 records on 6 lines. A parser that returns 6 records, or splits Smith, Jane, is wrong.
id,name,city,note
1,Ana,Lisbon,likes tea
2,Bo,Oslo,likes coffee, no sugar
3,Cy,Lyon,
4,Di,Rome,fine,
5,Ed,Bern
6,Flo,Kyiv,first line
second line of the note
7,Gus,Riga,okragged2.csv has 8 data lines and records of the wrong width: an unquoted comma in a note (5 fields), a trailing comma (5), a short row (3), and a note broken over two lines (the first half has 4 fields, the second 1). A library should either refuse it or tell you which lines are wrong.
id,zip,state
1,02134,MA
2,90210,CA
3,00501,NY
4,10001,NYzips.csv has ZIP codes with leading zeros, and zips-blank.csv is the same with an empty ZIP. bom.csv begins with the three bytes EF BB BF. unterminated.csv opens a quote on line 3 and never closes it, followed by 4,997 ordinary lines. And orders-2m.csv has eight columns and 2,000,000 rows. For each language I ran the same checks: parse people.csv correctly; report the wrong width; handle the unclosed quote; strip or leave the BOM; read ZIPs as text and as numbers; read a blank; write the awkward values; and time the sum of one column.
Speed and memory
The job is the simplest possible: read the file, add up the amount column, print the total. Every language got 160174646.36.
| Language and reader | Time | Peak memory |
|---|---|---|
Rust, csv crate, reused ByteRecord | 0.24 s | 1 MB |
R, data.table::fread | 0.26 s | 572 MB |
Rust, StringRecord / serde | 0.42 s / 0.40 s | 1 MB |
R, readr with cols_only | 0.31 s | 479 MB |
Go, encoding/csv, ReuseRecord | 0.98 s | 7 MB |
Java, OpenCSV readNext | 1.13 s | 240 MB |
| Java, Commons CSV | 1.15 s | 198 MB |
C#, CsvHelper GetField<double> | 1.43 s | 39 MB |
R, readr::read_csv (all columns) | 1.25 s | 469 MB |
Swift (macOS), byte parser over Data | 1.96 s | 599 MB |
Kotlin, kotlin-csv sequence | 3.89 s | 173 MB |
PHP 8.5, fgetcsv | 5.49 s | 2 MB |
Ruby 3.4, CSV.foreach as arrays | 9.66 s | 14 MB |
R, base read.csv | 12.47 s | 601 MB |
bash while read loop, counting lines only | 16.1 s | not measured |
For reference, on the same file and machine, Python's csv.reader took 1.52 s at 13 MB, Node's csv-parse 4.16 s at 74 MB, Papa Parse 1.12 s, and DuckDB 0.2 s, as the Python guide and the JavaScript guide show.
Four observations. The compiled languages with a purpose-built reader (Rust, and R's fread, which is C) are in a class of their own at a quarter to a third of a second, and everything that allocates a string per field is 1 to 10 seconds. Streaming is not the same as slow: Go, Rust, PHP and Ruby held 1 to 14 MB while reading the whole file, and the readers that loaded the file into memory (Ruby's CSV.read reached 1,974 MB, Kotlin's readAll 1,352 MB, Go's ReadAll 496 MB) paid for it. The JVM numbers (Java, Kotlin) are heap sizes the runtime chose and not what the program needed. And the bash loop, which does nothing with the data, is slower than every parser here by an order of magnitude, because bash is not designed for 125,000 iterations a second.
None of that decides which language to use. It decides whether a file of this size is a problem. At 2,000,000 rows, every reader finished in under 30 seconds; at 200,000,000 the Ruby and PHP times become hours, and that is where you stop writing a loop and load the file into a database or query it with DuckDB.
What happened on the awkward files
The behaviors that matter are the ones that go wrong without an error. This is the table I would have wanted before I started.
| Check | Go | Rust | Java (OpenCSV) | C# (CsvHelper) | R | PHP | Ruby | Kotlin |
|---|---|---|---|---|---|---|---|---|
| Quoted comma and line break | ok | ok | ok | ok | ok | ok | ok | ok |
| Unclosed quote | error, both lines | no error | error, line 3 | no error | warning only | no error | error, line 3 | error |
| BOM in first header | left in | stripped | left in | stripped | stripped | left in | stripped | stripped |
| Ragged rows | ErrFieldCount per record | UnequalLengths | no check | callbacks, no error | readr: problems(); fread: stops | no check | no check | exception |
| ZIP typed as integer | n/a (strings) | 2134 | 2134 | 2134 | readr: text; fread: 2134 | n/a | :numeric: 1116 | toInt: 2134 |
| Empty integer cell | n/a | type error | silently 0 | exception | NA | n/a | nil | toIntOrNull: null |
Look at three rows.
The unclosed quote is the most dangerous because the failure mode is a silent loss of structure. Go reported record on line 3; parse error on line 5000, naming both where the quote opened and where the parser gave up. Java reported Unterminated quoted field at end of CSV line with the text and line 3. Ruby said Unclosed quoted field in line 3. Kotlin threw MalformedCSVException, with a message of 226,650 characters because it quotes the lost text, so log only its first line. R's readers warned and returned a data frame. Rust, C# and PHP did not complain at all: Rust's second record was one 231,623 byte field, PHP's third line was 231,599 bytes. In those languages the check is yours: a maximum field length, and a record count compared with an independent tool.
The BOM is the least dangerous and the most frequent. Go, Java and PHP left it in the first header, so row["id"] fails on a file that looks right. The fix is the same everywhere: check the first three bytes, skip them, and do it in the one function that opens files.
Ragged rows differ most. Go and Rust return the broken record with an error you can inspect and keep reading. Kotlin throws. R's fread stops with a line number and a hint (Consider fill=TRUE), while readr returns the data and a problems() table. Java, PHP and Ruby do not check, and CsvHelper's default reports only missing fields through an optional callback. A library that does not check the width is not wrong, but it does make the check your job.
Typed reading and identifiers
Every typed reader tries to be helpful about numbers, and every one of them is wrong about ZIP codes in a different way. Rust with zip: u32, C# with int ZipCode, Java's bean with int zip, Kotlin's toInt() and Swift's Int(...) all returned 2134 for 02134 and 501 for 00501. R's readr returned the text 02134, because its guesser is careful with leading zeros, and fread and base read.csv returned the integer. Ruby is the odd one. converters: :numeric treated the leading zero as an octal prefix:
02134became the Integer 111600501became the Integer 32190210stayed 9021008902became the Float 8902.0, because 8 is not an octal digit
The result is a column that is a mixture of wrong integers, right integers and floats, with no warning. The rule that holds across all of them: identifiers are strings. Declare them as strings in the class, the spec or the converter, and the problem disappears; leave it to inference and each library will get it wrong in its own way.
Empty cells are the second place the typed readers part. An empty integer became 0 silently in OpenCSV beans, an exception in CsvHelper, a type error in Rust's serde, NA in R, nil in Ruby, and null from Kotlin's toIntOrNull(). Silent zero is the dangerous one, because it is a valid value. Make fields that may be empty nullable, and prefer the library that fails.
The locale trap
C# showed a failure that does not appear in the other languages, because the others default to a fixed number format. CsvHelper parses numbers with the CultureInfo you give it. Summing 200,000 amounts under en-US gave 16,018,429.85. Under de-DE, where . is the thousands separator, the same file gave 1,457,269,235.00, about 91 times too much: 103.1 was read as 1031. No exception was raised, since both strings are valid numbers in German. Use CultureInfo.InvariantCulture for files, always. R has the same hazard through locale = locale(decimal_mark = ",") in readr, but you opt in; Java's Double.parseDouble and Swift's Double(String) are locale-independent. If a developer's laptop and a server are in different locales and the code reads numbers, test with LC_ALL or the culture set both ways.
A recipe for each language
The shortest correct streaming loop, taken from the programs I ran:
| Language | Open | Loop | Settings I always change |
|---|---|---|---|
| Go | csv.NewReader(bufio.NewReaderSize(f, 1<<20)) | r.Read() until io.EOF | ReuseRecord = true for speed; handle ErrFieldCount; skip the BOM by hand |
| Rust | csv::ReaderBuilder::new().from_path(p) | rdr.records() or read_byte_record | flexible(false); check field length; serde String for IDs |
| Java | new CSVReader(new FileReader(p, UTF_8)) | readNext() | withEscapeChar('\0'); check row.length; skip the BOM; beans use Integer |
| C# | new CsvReader(new StreamReader(p), CultureInfo.InvariantCulture) | csv.Read() / GetRecords<T>() | PrepareHeaderForMatch; set MissingFieldFound and BadDataFound |
| R | readr::read_csv(p, col_types = cols(...)) or fread(p, colClasses = ...) | the data frame | declare ID columns; run problems(); set options(warn = 2) |
| PHP | fopen($p, 'r') | fgetcsv($h, null, ',', '"', '') | pass the empty escape; skip the BOM; check [null] rows |
| Ruby | CSV.foreach(p, headers: true) | the block | no :numeric for IDs; set nil_value and empty_value; add gem "csv" |
| Kotlin | csvReader().open(file) { ... } | readAllWithHeaderAsSequence() | keep insufficientFieldsRowBehaviour strict; log only the first line of errors |
| Swift | Data(contentsOf:) then a byte parser, or CSV<Named>(url:) | rows | never split on "\n"; check widths |
| Bash | mlr --icsv --otsv --headerless-tsv-output cat \| tr '\t' '\037' | IFS=$'\x1f' read -r ... | read -r; handle the last line without a newline |
The libraries are documented at pkg.go.dev/encoding/csv, docs.rs/csv, readr.tidyverse.org, the PHP manual and the Ruby CSV reference. The spokes explain each choice: Go, Java, R, Rust, C#, PHP, Ruby, bash, Kotlin and Swift.
Language by language, in one paragraph each
Go. encoding/csv is strict, small and streaming. It checks the field count from the first record, reports a *csv.ParseError with a line and a column, and continues after an error. It does not strip a BOM, and LazyQuotes is the escape hatch for a producer that quotes carelessly. ReuseRecord takes it from 1.60 s and 496 MB (ReadAll) to 0.98 s and 7 MB.
Java. OpenCSV's default escape character is a backslash, which turned "C:\temp\" into CsvMalformedLineException; pass '\0'. Its beans make an empty int into 0 without a captured exception, and its CSVWriter quotes every field, so NO_QUOTE_CHARACTER produces broken output. Commons CSV was as fast and has a CSVFormat builder worth reading.
R. Three readers, three answers: readr guesses text for leading zeros, fread and base R guess integers, and a blank is NA in readr and an empty string in fread. fread is fast, read.csv is slow (12.47 s), and problems() in readr is the audit trail.
Rust. The fastest, with the smallest footprint, serde into structs, and an UnequalLengths error with a position. It accepts an unclosed quote. Keep ID columns as String, and use Option<T> for fields that may be empty.
C#. CsvHelper maps to classes and fails with HeaderValidationException when the header case differs (Id against id). Most checks are callbacks that default to silent. Pass the invariant culture.
PHP. Pass all five arguments to fgetcsv; PHP 8.4 and later deprecate the implicit backslash escape, which swallowed the rest of a file after "C:\temp\". str_getcsv parses one record, and explode("\n") before it cuts multi-line records.
Ruby. csv is a bundled gem from 3.4, so Bundler needs it in the Gemfile or require raises LoadError. Unquoted empty is nil, quoted empty is "". Avoid :numeric. Slow, so stream.
Kotlin. kotlin-csv checks field counts by default, which is good, and exposes a lenient mode that silently reshapes rows, which is not. Error messages can contain the whole lost text.
Swift. No parser in Foundation, and "\r\n" is a single Character, so split(separator: "\n") on a CRLF file returned one piece. Parse bytes, or use SwiftCSV, which handles the BOM and quietly pads and trims ragged rows.
Bash. Do not loop over a CSV with IFS=,. Let a parser emit a safe format and read that, with a separator that is not whitespace.
Errors: values, exceptions, warnings and silence
The libraries fall into four styles of telling you something is wrong, and the style decides how easily a bad row becomes a bad import.
Go and Rust return errors as values. Read() hands back a record and an error, and the next call carries on. That makes the lossy loop a one-line mistake (continue on error) and the strict loop a one-line habit (return err). The error types carry a position, a *csv.ParseError with a line and column in Go, and UnequalLengths { pos, expected_len, len } in Rust, so a good loop collects them and fails at the end with a complete list.
Java, C#, Ruby and Kotlin throw exceptions, and the exception usually ends the read. OpenCSV's CsvMalformedLineException carries the line number, Ruby's MalformedCSVError has line_number, Kotlin's says on 2th csv row, and CsvHelper's HeaderValidationException names the missing header. Exceptions are a fine default for a batch job, and a poor one for a feed where you want to import the good rows and report the bad: there you catch per row, which in the Kotlin article became a runCatching around each conversion.
R warns. readr records problems in a table you must remember to look at, and fread throws a warning or an error depending on the case. In an interactive session the warning is on screen; in a script it is a line in a log, and the data frame is returned regardless. options(warn = 2) turns warnings into errors.
PHP and, for the width, Java and Ruby are silent. There is nothing to catch, so the check has to be code you write: a comparison of each record's length with the header's, a maximum field length, a record count against an independent count. I would put those three in a wrapper function the first time and never call the library directly after that.
Where you get a line number
A message that says "bad record" is not a message. The position information varies:
Go:
FieldPos(0)returns the line and column where a field started, and*csv.ParseErrorcarries both the record's start line and the line of the error. The unclosed quote saidrecord on line 3; parse error on line 5000.Rust:
rec.position().line()and thebyteoffset; the errors printrecord 2 (line: 3, byte: 41).Java:
getLinesRead()is the number of lines consumed so far, and the exception has a line number (3 for the unclosed quote, the line where it opened).C#:
csv.Parser.Rowis the current row; the exception message names headers, and the callbacks get the row.R: readr's
problems()has a row, column, expected and actual; fread names the line it stopped on.Ruby:
line_numberon the exception, andUnclosed quoted field in line 3.Kotlin:
on 2th csv rowin the message; the import job counts rows itself.PHP: none. Keep your own counter.
Swift: none in either approach.
Whatever the library gives you, a number that points to where the quote opened is the useful one, and a number that points to where the parser gave up is only a bound. Go and Java report the first, Ruby too, while Node's csv-parse reports the second. When you design your own errors, keep both.
Writing the same values
Reading is half of it. I wrote the same awkward values with each language's writer: a comma, a double quote, a line break, a leading space, a missing value and, where the type system allows, a float sum, a boolean and a date. The output follows the same rules for quoting (comma, quote and line break force quotes, and inner quotes are doubled) and differs on everything else:
A leading space was quoted by Go, PHP, Java and R's
write.csv, and left bare by Rust, Ruby,write_csvandfwrite. Both are valid; a reader that trims whitespace sees a difference.A missing value became an unquoted empty field in Ruby (
nil), PHP, Java (null), C# andfwrite, and the literalNAin R'swrite.csvandwrite_csv. Ruby alone wrote an empty string as"", so the two cases survive a round trip.Booleans were
True(C#),true(Ruby),TRUE(R),1and an empty field (PHP) and whatever your own formatting made them in Go and Rust.Floats were
0.30000000000000004in Ruby and R'swrite_csv,0.3in PHP (14 digits), C#'s decimal and R'sfwrite(15 digits). Format money yourself.Dates were
03/14/2025 10:15:00from C#,2025-03-14T10:15:00Zfrom R'swrite_csvandfwrite, and2025-03-14 10:15:00fromwrite.csv.Quote-everything: Java's OpenCSV default quoted every field, including the numbers, and its
NO_QUOTE_CHARACTERmode produced1,Smith", Jane,said ""hi"", which no reader accepts.A formula,
=SUM(A1), was written unchanged by every writer I tried, with no guard for spreadsheets.
The rule is the same as for reading: say what you want for every type that is not plain text, and test the output with a different language's reader.
Choosing, if you have a choice
If the work is analysis on a file that fits in memory, R's fread or a DuckDB query will be faster to write and faster to run than any loop. If it is a service that imports uploads, use the reader that fails loudly and gives line numbers: Go's, Java's OpenCSV with the escape fixed, or Rust's with a field-length check, and wrap it so that every call site gets the same checks. If it is a command line tool for yourself, the shell tools in the shell guide are quicker than any compiled program for a first look. If you are on a platform with no good package, such as Swift, a 27 line byte parser is correct and small, and it is better than splitting strings. And if the process ends in a database, skip the language: COPY, LOAD DATA or .import parse the file in the server, with the options you set once. The language library earns its place when the rows need logic that SQL cannot say.
A portable test list
If you use a language that is not here, or a library that is, these are the checks to write before you trust it. Each takes a few lines and finds one of the defects above.
people.csvreturns exactly 4 records, withSmith, Janeas one field,said "hello"unquoted and the note holding a line break.ragged2.csveither raises or reports the lines 3, 5, 6 and 8, and the record count is stated.unterminated.csvraises, or a maximum field length is enforced.bom.csvhas a first header of exactlyid.zips.csvyields02134as text where the column is an identifier.zips-blank.csvdistinguishes the blank from 0 and from the empty string, on purpose.A file with CRLF line endings and a file without a final newline both give the same records as the plain file.
A decimal like
103.1parses the same under two locales.Writing
Smith, Jane,said "hi", a line break and a leading space, then reading the result back with another tool, returns the original.The 2,000,000 row file finishes, with memory that does not grow with the file.
The ragged file is worth looking at again, because it exposes the width checks:
id,name,city,note
1,Ana,Lisbon,likes tea
2,Bo,Oslo,likes coffee, no sugar
3,Cy,Lyon,
4,Di,Rome,fine,
5,Ed,Bern
6,Flo,Kyiv,first line
second line of the note
7,Gus,Riga,okLine 3 has an unquoted comma in the note, line 4 is fine (an empty last field is legal), line 5 has a trailing comma, line 6 is a short row, and lines 7 and 8 are one note broken across two lines. A library that reports four problems, on lines 3, 5, 6 and 8, has told you the truth.
Using the list in a pipeline
The ten checks are most valuable as a test that runs whenever a dependency is upgraded. I keep the files in the repository next to the import code, with an expected-results file per fixture: the number of records, the first header, the list of rejected lines. The test reads each fixture through the production reader, not a copy of it, and compares. When a library bump changes how a BOM or a bare quote is treated, the test fails in the pull request instead of in the nightly import. The cost is a dozen small files and a table of expectations; the benefit is that "does this library handle our files" stops being an opinion. Add a fixture every time a customer's file teaches you something new, and the list becomes the specification of what your importer accepts.
How to tell it worked
Run the ten checks on the version of the library you ship, and keep them as a test. Print the first header with a representation that shows invisible characters. Compare the record count with an independent tool such as qsv count or a DuckDB query. For typed readers, add a row with a blank in every nullable column. For writers, read the output with a different language. And pin the library version: several of the defaults above are the kind that change in a major release.
What this comparison cannot tell you
It is one machine, one run per timing, a handful of files and the library versions in the callout; a different CPU, a different JVM flag or a newer release will change the numbers, though not the shape of the table. It does not cover Windows, locale-specific defaults beyond the C# case, encodings other than UTF-8 and Windows-1252, or huge individual fields. It measures reading a simple column, not the cost of mapping into rich objects. And it says nothing about the other half of an import, getting the data into a database, where the right answer is usually the database's bulk loader, as the PostgreSQL guide shows. The format that all of these libraries implement, with varying strictness, is RFC 4180; when a library and the RFC disagree, the RFC is on your side.


