The advice for exporting CSV is to use a real library rather than joining strings with commas. That advice is right, and it solves a different problem from the one most people think it solves. We wrote the same six values three ways and opened each result in a spreadsheet.

Three writers, one payload

writer            bytes   rows read back   "Tan, Jeffrey"   =1+1
Python csv          153        6            intact           executed
PHP fputcsv         148        6            intact           executed
comma join          138        7            "Tan"            executed

The naive version fails the way everyone expects. A field containing a comma becomes two fields, so Tan, Jeffrey arrives as Tan. A field containing a newline becomes two records, which is why six rows were written and seven were read back. That is the case the libraries exist for, and both libraries handle it.

The two correct writers also do not produce identical files. Python quotes only what needs quoting and ends lines with CRLF; PHP quotes any field containing a space and ends lines with LF. Both files are valid, they differ by five bytes, and a byte comparison between them proves nothing about either.

Now read the last column. All three executed the formula.

Quoting is not a defence

One might assume a proper CSV library would have quoted the dangerous value, and that quoting would mark it as text. We wrote the same value twice, once bare and once forced into quotes, so the two lines differ only in the enclosure:

equals_bare,=1+1
equals_quoted,"=1+1"

Both became a formula cell holding 1+1, with a cached result of 2. Quotes in CSV delimit a field. They say where the value ends; they do not say what the value is. The spreadsheet strips them and then decides what it is looking at, exactly as it does for an unquoted field.

No CSV writer, however careful, can hand a spreadsheet this value in a way that makes it text. The format itself has no way to express the difference.

Which characters actually fired

The usual guidance names four dangerous leading characters: equals, plus, minus and at. Measured here, one of them fired:

=1+1          became a formula
+1+1          stayed text
-1+1          stayed text
@SUM(1,1)     stayed text

That list comes from Excel, and it is a reasonable list to defend against. It is not a description of what every spreadsheet does. A value that imports as text on your spreadsheet may still become a formula on your recipient's, and that gap runs in the direction that catches people out.

Defend against all four. Verify on the tool you actually have, and expect the answer to differ.

The mitigations, and what they cost

Two prefixes stop it. A leading apostrophe and a leading tab both leave the cell as text.

'=1+1     stays text — stored as '=1+1
\t=1+1    stays text — stored as \t=1+1

Read those stored values carefully. The prefix is still there. A leading apostrophe typed into a spreadsheet is a marker the application eats. This one arrived through a CSV, so the apostrophe is data. Every consumer that is not a spreadsheet now reads a value one character longer than the one you exported.

That is the trade. You can protect the spreadsheet user by corrupting the file for every other tool, or you can ship the file you meant and leave the spreadsheet to behave as it will.

What to do instead

The right approach depends on who consumes the file, and the two cases have opposite answers. If the file is going into a program, ship correct CSV and do not mangle it. If the file is going to a person who will open it in a spreadsheet, CSV is the wrong container — write a real workbook with typed cells, where "this is text" is something the format can say.

If it must be CSV and it must be safe, escape at the boundary and document it. Prefix the four leading characters, and say in the export's documentation that you do, because somebody downstream will otherwise spend an afternoon on a stray apostrophe.

Do not treat "we use a proper CSV library" as a security answer. It is a correctness answer. It buys you fields that survive commas, quotes and newlines, and it buys nothing at all against a value that begins with an equals sign.

The complete fix belongs to the receiver, not the sender. Importing with every column set to Text preserved all twenty-six values in the companion measurement, formula included. That works, and you cannot do it on somebody else's behalf.

How this was measured, and what it does not establish

Six values written three ways — Python's csv module, PHP's fputcsv, and string concatenation — each opened by LibreOffice 26.2.4.2 headless with default import options, then read out of the resulting workbook's XML rather than from a re-export, so that a formula cell is visible as a formula rather than as its cached result.

One spreadsheet, one version. Every row above is a fact about LibreOffice 26.2.4.2. Excel, Google Sheets and Numbers each make their own decisions, and the character list above is the clearest evidence that they differ.

Defaults move. PHP 8.5 deprecates the implicit escape argument to fputcsv, because its historical default was a backslash escape that RFC 4180 does not describe. Even a decade-stable behaviour is a default, and defaults change.

Nothing here was executed as an attack. The formula measured was arithmetic. This measurement did not test whether a spreadsheet would run a formula with an external effect, such as a network call, or what it would warn first.

What the receiving end does to a file that was written correctly is the other half, and it changes more values than a file comparison can find.