Skip to content
datahacks.dev
CSV

Exporting a MongoDB Collection to CSV With mongoexport

mongoexport writes CSV only if you name the fields. See arrays, ObjectIds, nulls, the empty file and exit 0 on a typo, and 500,000 documents in 2.7 s.

10 min read
Summarize in AI
Flat illustration of a file in one format being converted into another, with the MongoDB logo.

A MongoDB document can hold arrays, nested documents and values that have no CSV spelling (the extended JSON reference lists the types). A CSV row is flat text. mongoexport is the official tool that bridges them, and it does so by making you say which fields you want, one column each. That is a feature: the shape of the file is a decision, and the tool will not make it for you. This article exports a small collection built to be awkward and a larger one built to be big, and looks closely at the places where a document does not fit in a cell.

The collection

Eight people with the awkward values the other export articles use: a comma, double quotes, a newline, an empty string, a null, non-ASCII text. They are not uniform. Document 6 has no active field and no joined; document 4 has a null address and document 8 has none; tags is an array of zero to three strings and address is a nested document. That is normal for a document database, and it is the reason the export needs a plan.

You have to name the fields

Shell
mongoexport --db lab --collection ex_people --type=csv --out /tmp/x.csv 2>&1 | sed 's/^[0-9T:.+-]*\t//'
echo "exit status: ${PIPESTATUS[0]}"
Text
connected to: mongodb://localhost/
Failed: CSV mode requires a field list
exit status: 1

mongoexport in CSV mode stops with Failed: CSV mode requires a field list, exit status 1. There is no "all fields" option, because two documents in a collection need not have the same fields, and a column list has to come from somewhere. The reference page documents --fields and --fieldFile, which reads one field name per line from a file. For a collection you control, keep that file in version control with the export script. It is the schema of the export.

The basic export

Shell
cd /batches/csv-0001-0200
mongoexport --db lab --collection ex_people --type=csv --fields _id,name,note,active,score,joined --sort '{_id: 1}' --out work/export/mongo_basic.csv 2>&1 | sed 's/^[0-9T:.+-]*\t//'
cat -A work/export/mongo_basic.csv | sed -n '1,3p;5,7p'
Text
connected to: mongodb://localhost/
exported 8 records
_id,name,note,active,score,joined$
1,Ana Silva,plain,true,12.50,2025-03-01T09:30:00.000Z$
2,"O'Neil, Pat","has, a comma",false,0,2025-03-02T23:59:59.000Z$
4,Cy,"line one$
line two",true,,2025-03-04T12:00:00.000Z$
5,Di,,false,3.14,2025-03-05T08:15:30.000Z$

The status lines (connected to, exported 8 records) go to standard error, and the CSV goes to the --out file or to standard output. cat -A shows what was written. The header is the field list, _id,name,.... Quoting follows the CSV rules: "O'Neil, Pat" quoted for the comma, and the note with a newline quoted across two lines. The numeric score stored as a Decimal128 printed as 12.50, keeping its trailing zero, and 0 for a zero. I read the whole file with Python's csv module and all eight name and note pairs matched. I added --sort '{_id: 1}' so the order is stable; without it, the order is whatever the collection scan returns.

Arrays and nested documents

Shell
cd /batches/csv-0001-0200
mongoexport --db lab --collection ex_people --type=csv --fields _id,tags,address.city,tags.0 --sort '{_id: 1}' 2>/dev/null
Text
_id,tags,address.city,tags.0
1,"[""new"",""vip""]",Lisbon,new
2,[],Cork,
3,"[""a,b""]",Taipei,"a,b"
4,"[""x""]",,x
5,"[""x"",""y"",""z""]",,x
6,,Oslo,
7,"[""é""]",Izmir,é
8,[],,

An array in a field is written as JSON text in one quoted cell: "[""new"",""vip""]". That is correct CSV under RFC 4180, with the quote characters doubled, and it is not a column per element. A consumer has to parse the JSON out of the cell. An empty array is []. A nested document can be reached with a dotted path: address.city became a column holding Lisbon, Cork, Taipei, and an empty value where the document had no address (documents 5 and 8) or a null one (document 4). An array element can be reached by position, tags.0, which gave new, an empty cell for an empty array and a,b for the element that contains a comma, quoted properly.

What the tool cannot do is turn an array into several rows. A document with three tags is one row. If you want a row per tag (a long format that groups and joins well), mongoexport cannot make it: it takes a query, not an aggregation pipeline, and I could not find an option for one. Use mongosh or a driver with $unwind and write the CSV from there, or export as it is and explode the column in pandas.

What the values look like

Values that have no plain-text form get a text form. These are the ones I tried, in one document:

Shell
mongosh --quiet lab --eval '
db.ex_types.drop();
db.ex_types.insertOne({ _id: ObjectId("66b1f0c2a1b2c3d4e5f60718"), n: 42, big: NumberLong("9007199254740993"), f: 0.1 + 0.2, d: ISODate("2025-03-01T09:30:00.123Z"), flag: false, u: UUID("123e4567-e89b-12d3-a456-426614174000"), bin: BinData(0, "aGVsbG8="), nul: null });
'
mongoexport --db lab --collection ex_types --type=csv --fields _id,n,big,f,d,flag,u,bin,nul 2>/dev/null
Text
{
  acknowledged: true,
  insertedId: ObjectId('66b1f0c2a1b2c3d4e5f60718')
}
_id,n,big,f,d,flag,u,bin,nul
ObjectId(66b1f0c2a1b2c3d4e5f60718),42,9007199254740993,0.30000000000000004,2025-03-01T09:30:00.123Z,false,123E4567E89B12D3A456426614174000,68656C6C6F,
  • An ObjectId was written as ObjectId(66b1f0c2a1b2c3d4e5f60718), with the wrapper, not the 24 hex digits alone. Anything that joins on the id has to strip it.

  • A UUID was 32 uppercase hex digits with no dashes. A binary value was hex too (68656C6C6F for hello).

  • A 64-bit integer printed exactly (9007199254740993), and a double printed in full (0.30000000000000004, the result of 0.1 + 0.2).

  • A date was ISO 8601 in UTC with milliseconds and a Z: 2025-03-01T09:30:00.123Z. That is the one part that is easy for the next tool to read.

  • A null was an empty field.

The text forms are stable, which is what matters for a repeatable export, and none of them is what you would write by hand. Writing dates and booleans others can read covers the same decisions for SQL databases. If the consumer is another program, check how it reads each of them, and if it is a person, add the conversion on the consuming side.

Missing, null and empty are the same

Three different states in Mongo:

Shell
cd /batches/csv-0001-0200
mongoexport --db lab --collection ex_people --type=csv --fields _id,note,active --sort '{_id: 1}' 2>/dev/null | sed -n '1p;5,7p'
mongosh --quiet lab --eval 'printjson(db.ex_people.find({_id: {$in: [5, 6]}}, {note: 1, active: 1}).toArray())'
Text
_id,note,active
4,"line one
line two",true
5,,false
[
  {
    _id: 5,
    note: '',
    active: false
  },
  {
    _id: 6,
    note: null
  }
]

Document 5 has note: '' and active: false. Document 6 has note: null and no active field at all. In the CSV, the note of both is an empty field, and document 6's active is empty. The second half of the output is the same two documents from mongosh, which does show the difference. The CSV cannot. If it matters, export a marker column that you compute (hasNote) or use a different format such as JSON Lines, which keeps nulls and absences distinct.

Filtering, ordering, paging

--query takes a filter, --sort an ordering, and --skip and --limit page through results. The query has to be strict JSON:

Shell
cd /batches/csv-0001-0200
mongoexport --db lab --collection ex_people --type=csv --fields _id,name,active --query '{active: true}' 2>&1 | sed 's/^[0-9T:.+-]*\t//'
echo ---
mongoexport --db lab --collection ex_people --type=csv --fields _id,name,active,score --query '{"active": true, "score": {"$gt": 5}}' --sort '{"_id": 1}' 2>&1 | sed 's/^[0-9T:.+-]*\t//'
echo ---
mongoexport --db lab --collection ex_people --type=csv --fields _id,name --noHeaderLine --limit 2 --skip 1 --sort '{"_id": 1}' 2>/dev/null
Text
connected to: mongodb://localhost/
Failed: error parsing query as Extended JSON: invalid JSON input
---
connected to: mongodb://localhost/
_id,name,active,score
1,Ana Silva,true,12.50
3,"Bo ""Bee"" Chen",true,7
7,Zoë Ünal,true,1234.50
exported 3 records
---
2,"O'Neil, Pat"
3,"Bo ""Bee"" Chen"

The first attempt, {active: true} with unquoted keys, failed with error parsing query as Extended JSON: invalid JSON input, even though the same sloppy style worked in --sort earlier. The second, with quoted keys and "$gt" quoted, exported the three documents that matched (ids 1, 3 and 7), and the third used --noHeaderLine, --skip 1 and --limit 2 to get ids 2 and 3 with no header. --noHeaderLine is the option for appending to a file or for concatenating several exports; mixing files with and without a header is a common source of duplicated header rows, which the cleaning series covers.

A typo exports nothing, successfully

Shell
cd /batches/csv-0001-0200
mongoexport --db lab --collection ex_peple --type=csv --fields _id,name --out work/export/typo.csv 2>&1 | sed 's/^[0-9T:.+-]*\t//'; echo "exit status: ${PIPESTATUS[0]}"; wc -c work/export/typo.csv
mongoexport --db lab --collection ex_peple --type=csv --fields _id,name --assertExists --out work/export/typo2.csv 2>&1 | sed 's/^[0-9T:.+-]*\t//'; echo "exit status: ${PIPESTATUS[0]}"
Text
connected to: mongodb://localhost/
exported 0 records
exit status: 0
0 work/export/typo.csv
connected to: mongodb://localhost/
Failed: collection `ex_peple` does not exist
exit status: 1

I misspelled the collection name. mongoexport printed exported 0 records, wrote a 0 byte file (not even a header) and exited with status 0. A scheduled job that checks only the exit status calls that success. The option --assertExists makes the export fail if the collection does not exist: the second run stopped with `collection ex_peple does not exist` and exit status 1. A collection that exists and matches no documents still exports zero records without an error, so check the record count against what you expect, as the counting article describes.

How fast it is

For a larger collection, 500,000 documents with five fields each:

Shell
cd /batches/csv-0001-0200
mongosh --quiet lab --eval '
db.ex_big.drop();
let batch = [];
for (let i = 1; i <= 500000; i++) {
  batch.push({ _id: i, customer: "user" + (i % 9000), amount: Math.round((i * 7919 % 100000)) / 100, created: new Date(1740787200000 + i * 60000), status: ["paid","pending","refunded"][i % 3] });
  if (batch.length === 10000) { db.ex_big.insertMany(batch); batch = []; }
}
print(db.ex_big.countDocuments());
'
TIMEFORMAT="mongoexport csv: %R s"; time mongoexport --db lab --collection ex_big --type=csv --fields _id,customer,amount,created,status --sort '{"_id": 1}' --out work/export/mongo_big.csv 2>&1 | sed 's/^[0-9T:.+-]*\t//'
head -n 3 work/export/mongo_big.csv; wc -l work/export/mongo_big.csv
Text
500000
connected to: mongodb://localhost/
[########................]  lab.ex_big  168000/500000  (33.6%)
[################........]  lab.ex_big  352000/500000  (70.4%)
[########################]  lab.ex_big  500000/500000  (100.0%)
exported 500000 records
mongoexport csv: 2.690 s
_id,customer,amount,created,status
1,user1,79.19,2025-03-01T00:01:00.000Z,pending
2,user2,158.38,2025-03-01T00:02:00.000Z,refunded
500001 work/export/mongo_big.csv

The export took 2.69 s and wrote 500,001 lines, the header and one per document, with the progress meter on standard error. Dates came out as ISO 8601 in UTC, as above. This is one machine with the database and the tool side by side, so treat the number as a floor; across a network, the transfer will dominate.

How to tell it worked

Count the records in the file and compare with countDocuments() for the same query. Read the file with a CSV parser and look at the field-count histogram. Pick the documents that are least like the others (missing fields, arrays, nulls, long strings) and compare them with the file by eye. Run the export against a collection name you know does not exist and see that your job fails. And keep the field list and the query in version control, since they are the definition of the file; the validation guide shows what to check on the receiving side.

When the documents are not flat

The tool takes a one-to-one view of a collection, and the more variation there is, the less one-to-one a CSV is. Heterogeneous documents give you a wide file with many empty cells. Arrays of sub-documents have no good CSV form at all: a row per element with the parent's fields repeated is the usual answer, and it has to be built with an aggregation. And if the next stop for the data is another document system, JSON Lines (mongoexport without --type=csv writes it) loses nothing, where CSV loses the difference between missing, null and empty. The comparison of every database's export puts this next to the SQL engines. Use CSV when the consumer is a spreadsheet, a BI tool or a SQL table with a fixed list of columns, and write down the field list when you do.

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.