Advice about GeoJSON file size usually ends with a suggestion to use something else. That advice almost never says what the something else costs, so this page measures both halves: how much smaller each format is, and what it drops on the way through.
The size half uses Natural Earth 1:10m Admin 0 Countries, which is public domain. It has 258 features and 548,471 positions, and it is the same file the file size guide and the simplification benchmark use, so one dataset carries across all three pages.
Both halves cover nine formats, plus GeoJSON itself twice: once written by each of the two tools used here, as a control.
The fidelity half uses 23 documents of two or three features each, written on purpose. Each one isolates a single thing a format might not be able to carry: a property name longer than ten characters, a null value where an empty string would do, a polygon whose outline runs clockwise.
Size, before and after compression
Every row covers the whole output, every file of it. Some conversions write more than one: a shapefile is 4 files before anyone counts, and the GML writer adds a schema beside the data. The compressed column gzips each file on its own, which is what a zip of the folder does.
| Format | Files | On disk | Saved | Gzipped | Saved | Same bytes twice |
|---|---|---|---|---|---|---|
| TopoJSONwritten by mapshaper | 1 | 4.2 MB | 82% | 1.3 MB | 80% | yes |
| FlatGeobufwith its built-in spatial index | 1 | 8.5 MB | 64% | 4.5 MB | 30% | yes |
| ShapefileESRI Shapefile, written to a directory | 4 | 8.5 MB | 64% | 4.6 MB | 29% | yes |
| GeoPackageOGC GeoPackage on SQLite | 1 | 8.7 MB | 63% | 4.6 MB | 29% | no |
| GeoJSON Text SequencesRFC 8142, one feature per line | 1 | 15.0 MB | 36% | 4.8 MB | 26% | yes |
| SQLiteSpatialite geometry in SQLite | 1 | 15.3 MB | 34% | 5.2 MB | 21% | no |
| CSVgeometry as a WKT column | 1 | 16.8 MB | 28% | 5.7 MB | 13% | yes |
| GMLGeography Markup Language | 2 | 17.7 MB | 24% | 5.8 MB | 11% | yes |
| KMLLIBKML | 1 | 28.0 MB | -20% | 6.1 MB | 6% | yes |
| GeoJSON via mapshapermapshaper GeoJSON to GeoJSON, the control for TopoJSON | 1 | 20.5 MB | 12% | 6.2 MB | 4% | yes |
| GeoJSONogr2ogr GeoJSON to GeoJSON, the control | 1 | 23.3 MB | - | 6.5 MB | - | yes |
Saved is measured against GeoJSON as written by the same tool, at 23.3 MB on disk and 6.5 MB gzipped.
Half the binary saving belongs to gzip
The three binary formats cut the file by 63% to 64% on disk. Over HTTP, where the server is compressing anyway, the same three save 29% to 30%. GeoJSON is repetitive text and gzip is good at repetitive text, so a large part of what a binary format appears to win is a saving gzip would have given for free.
The ordering changes too, which is the practical version of the same point. KML is 20% larger than GeoJSON on disk and 6% smaller once compressed. Reading the uncompressed column alone would have ranked it last.
The container matters as much as the container being binary. SQLite carrying Spatialite geometry is 15.3 MB, against 8.7 MB for a GeoPackage holding the same features. Both are SQLite files. They differ in how the geometry is encoded, and that alone is most of the gap.
TopoJSON is the exception that makes the rule readable. It saves 82% on disk and 80% compressed. Every shared border is stored one time and referenced by both countries, so the duplicate is gone from the file and gzip has nothing left to squeeze out of it.
The spatial index is close to free
Both GeoPackage and FlatGeobuf build a spatial index, so a reader can find the features inside a map view without reading the whole file. The structure is called an R-tree, which groups nearby shapes into boxes and those boxes into larger ones. Each format was written twice, once with the index and once without, so the cost below is a measurement.
- GeoPackage R-tree: 28,672 bytes, 0.31% of the file.
- FlatGeobuf packed Hilbert R-tree: 11,112 bytes, 0.12% of the file.
Turning the index off to save space is not worth thinking about. The index is under half a percent of both files.
Two formats do not write the same file twice
Each format was written twice from the same input and the bytes compared, and two of them differed: SQLite and GeoPackage. The cause in both cases is a clock.
A GeoPackage records when each table last changed, to the millisecond, in a column called last_change. Spatialite keeps three tables of its own with timestamps in them: spatialite_history, geometry_columns_statistics and geometry_columns_time. Two conversions of the same source a moment apart differ in exactly those bytes and nowhere else.
It matters if you keep converted data in version control, because both will show a change on every rebuild whether or not the data moved. The other nine are byte for byte identical from one run to the next.
What survives the round trip
Each of the 23 documents was converted to the format and straight back to GeoJSON, then compared with what went in. A cell reads same when everything came back: the same features, the same property names and values, the same coordinates in the same order.
Read every column against two things. The first is the GeoJSON column, which converts GeoJSON to GeoJSON and changes nothing in all 23 of 23 cases. It is there so that a loss elsewhere can be attributed to the format itself. The second is the top row, a feature with nothing unusual in it. Two of these formats change every feature they touch, and the top row is how much of that change has nothing to do with the probe below it. What a format does to every feature is written once on that top row and left out of the rows beneath, so each cell says what its own probe provoked.
| Probe | GeoJSONcontrol | GeoJSON via mapshapercontrol | TopoJSON | GeoJSON Text Sequences | GeoPackage | FlatGeobuf | Shapefile | SQLite | KML | GML | CSV |
|---|---|---|---|---|---|---|---|---|---|---|---|
| plain featureWhat does the format do to a feature with nothing unusual in it? | same | same | same | same | same | same | same | same | altersname renamed to Name; positions went from 2 to 3 elements; added extrude, tessellate, visibility | addsadded gml_id | same |
| long field nameDoes a property name longer than ten characters survive? | same | same | same | same | same | same | alterspopulation_density_per_km2 renamed to population | same | altersas the top row | addsas the top row | same |
| colliding field namesWhat happens to two property names that share their first ten characters? | same | same | same | same | same | same | altersdescription_short renamed to descriptio, description_long renamed to descript_1 | same | altersas the top row | addsas the top row | same |
| long string valueDoes a 300 character string survive? | same | same | same | same | same | same | alterstext cut from 300 to 254 characters | same | altersas the top row | addsas the top row | same |
| unicode textDoes non-ASCII text come back unchanged? | same | same | same | same | same | same | altersname became "Ångström ?? ???????????" | same | altersas the top row | addsas the top row | same |
| null propertyDoes a null property value stay null? | same | same | same | same | same | same | same | same | altersdropped note | addsas the top row | altersnote became "" |
| empty stringDoes an empty string stay an empty string? | same | same | same | same | same | same | altersnote became null | same | altersdropped note | altersdropped note | same |
| boolean propertyDoes a boolean come back as a boolean? | same | same | same | same | same | same | same | same | altersas the top row | addsas the top row | same |
| big integerDoes an integer above the 32 bit range survive? | same | same | same | same | same | same | same | same | altershuge became 9007199254740990 | addsas the top row | same |
| high precision floatDoes a number with seventeen significant digits come back intact? | same | same | same | same | same | same | altersratio became 0.123456789012346 | same | altersratio became 0.123456789012346 | altersratio became 0.123456789012346 | altersratio became 0.123456789012346 |
| nested objectDoes an object-valued property survive? | same | same | same | same | same | same | same | same | altersas the top row | addsas the top row | same |
| array propertyDoes an array-valued property survive? | same | same | same | same | same | refusesrefused to write the file | altersdropped counts, tags | same | alterstags became "(3:a,b,c)"; counts became 0 | addsas the top row | same |
| property named fidWhat happens to a property named fid? | same | same | same | same | altersdropped fid | same | same | same | altersas the top row | addsas the top row | same |
| property named idWhat happens to a property named id? | same | same | same | same | altersdropped id | same | same | altersdropped id | altersas the top row | addsas the top row | same |
| feature idDoes a Feature's top level id member survive? | same | same | same | altersmoved the Feature id into the properties | altersmoved the Feature id into the properties | altersmoved the Feature id into the properties | altersmoved the Feature id into the properties | altersmoved the Feature id into the properties | altersmoved the Feature id into the properties | altersmoved the Feature id into the properties | altersmoved the Feature id into the properties |
| null geometryDoes a Feature with a null geometry survive? | same | same | same | same | same | same | same | same | altersas the top row | addsas the top row | same |
| mixed geometry typesCan one file hold a Point, a LineString and a Polygon? | same | same | altersmoved a coordinate by 119 metres | same | same | same | refusesrefused to write the file | same | altersas the top row | addsas the top row | same |
| polygon and multipolygonDoes a Polygon stay a Polygon when a MultiPolygon shares the file? | same | same | altersmoved a coordinate by 423 metres | same | same | altersPolygon and MultiPolygon came back as MultiPolygon and Polygon; changed the feature order | altersreversed the ring winding | same | altersas the top row | addsas the top row | same |
| geometry collectionDoes a GeometryCollection survive? | same | alters1 feature became 2; GeometryCollection came back as Point and LineString | alters1 feature became 2; GeometryCollection came back as Point and LineString | same | same | same | refusesrefused to write the file | same | altersas the top row | addsas the top row | same |
| polygon windingDoes a counterclockwise exterior ring come back counterclockwise? | same | same | same | same | same | same | altersreversed the ring winding | same | altersas the top row | addsas the top row | same |
| coordinate precisionDo fifteen decimal places of longitude come back unchanged? | same | same | same | altersmoved a coordinate by 2.3 millimetres | same | same | same | same | altersmoved a coordinate by under a micrometre | altersmoved a coordinate by under a micrometre | altersmoved a coordinate by under a micrometre |
| z coordinatesDoes the third element of a position survive? | same | same | same | same | same | same | same | same | altersas the top row | addsas the top row | same |
| four element positionDoes a fourth element in a position survive? | same | same | same | alterspositions went from 4 to 3 elements | alterspositions went from 4 to 3 elements | refusesrefused to write the file | alterspositions went from 4 to 2 elements | refusesrefused to write the file | altersas the top row | alterspositions went from 4 to 3 elements | alterspositions went from 4 to 3 elements |
| Format | Returned unchanged |
|---|---|
| TopoJSON | 20 of 23 |
| GeoJSON Text Sequences | 20 of 23 |
| SQLite | 20 of 23 |
| GeoPackage | 19 of 23 |
| FlatGeobuf | 19 of 23 |
| CSV | 18 of 23 |
| Shapefile | 10 of 23 |
| KML | 0 of 23 |
| GML | 0 of 23 |
The results worth reading twice
A shapefile hands back polygons wound the wrong way
Winding order is the direction a ring is drawn, clockwise or counterclockwise. RFC 7946 requires the right-hand rule, which means the outer ring of a polygon runs counterclockwise. The shapefile specification requires the opposite. Every polygon that goes through a shapefile comes back reversed, so a file that was correct before the conversion is not correct after it.
Nothing reports this. The geometry is still closed, still valid, still the same shape. It goes wrong only where the winding is what tells an outer ring from a hole, and then it goes wrong quietly. Almost no validator checks winding either, so nothing downstream will tell you.
A shapefile replaces text it cannot encode
The probe value is Ångström 北京 Владивосток and it comes back as Ångström ?? ???????????. The accented Latin survives. The Chinese and the Cyrillic become question marks, one per character, with no error and no warning.
The 300 character value is cut to 254 characters, also in silence. A property name of 26 characters comes back as its first ten. Two names that share their first ten characters come back as descriptio and descript_1, so the second one is renamed to stay distinct and no longer matches any code that reads it.
A property named id does not come back
GeoPackage and SQLite give every row a primary key. A property called id wants the same slot, and the key wins: the property is consumed and is not written back out. GeoPackage does the same to a property called fid. The value is not corrupted or truncated. It is simply gone, and a conversion that dropped one column out of twenty is easy to miss.
A Feature's id member sits beside properties, one level up. It survives only in the three JSON formats. Everywhere else it comes back as an ordinary property named id. The value is still there, one level lower, so code reading feature.id stops finding it.
Two GeoJSON formats from one library, two precisions
GeoJSON and GeoJSON Text Sequences are the same data model written two ways, and the same tool writes both. The plain writer keeps all fifteen decimal places it is given. The sequence writer rounds to seven, which moves a coordinate by about 2.3 millimetres.
That distance does not matter. The point is that the default differs between two formats nobody would expect to differ, so a precision guarantee has to be checked against the writer that produced the file. The CSV, KML and GML paths round in a third way again, at fifteen significant digits, because each writes the geometry as text.
TopoJSON rounds coordinates by design
Quantization means snapping every coordinate to a grid so the numbers can be stored as small whole-number steps. It is most of why TopoJSON is 80% smaller, and it is lossy: the largest move across these probes is 423 metres. For a world map that is invisible. For anything measured it is not, and it is on by default.
Some conversions refuse outright
- FlatGeobuf, array property: the conversion stopped and wrote nothing.
- Shapefile, mixed geometry types: the conversion stopped and wrote nothing.
- Shapefile, geometry collection: the conversion stopped and wrote nothing.
- FlatGeobuf, four element position: the conversion stopped and wrote nothing.
- SQLite, four element position: the conversion stopped and wrote nothing.
A refusal is the best of the outcomes on this page. It happens while someone is watching, and it cannot be discovered six months later in a file nobody checked.
Mixed geometry types get split up
A document holding a Point, a LineString and a Polygon is one file in GeoJSON. Converting it with mapshaper writes 3 files, one per type. Converting it to a shapefile fails outright. GeoPackage, SQLite and FlatGeobuf take it whole.
A GeometryCollection is handled the same way where it is handled at all. mapshaper and TopoJSON flatten one collection into 2 separate features, which keeps the geometry and loses the grouping.
KML and GML rewrite every feature
Both score zero in the table, and the top row says why. KML adds extrude, tessellate, visibility to every feature and gives every position a third element set to zero. GML adds a gml_id to every feature. Neither is a reaction to anything in the probes, and once those are set aside the two behave much like the rest.
What to do with this
- Serving a web map. Compression is doing more for you than a format change would. If the file is still too large after gzip, TopoJSON is the one format here that saves something gzip cannot, and the cost is rounded coordinates.
- Handing data to someone else. GeoPackage and FlatGeobuf came through nearly everything, and their indexes are free. Rename any property called
idorfidfirst. - Producing a shapefile. Check property names against ten characters, string values against 254, and text against ASCII before converting. Then rewind the polygons on the way back.
- Keeping converted data in git. Every format here except GeoPackage and SQLite writes the same bytes from the same input. Those two stamp the file with the time it was written.
- Whatever you convert. Convert it back and compare. That is all this page does. Across nine formats and 23 probes there are 207 cells, and 126 of them came back unchanged.
Method
Every figure here was produced by one script. Versions: gdal 3.8.4, mapshaper 0.7.61. Conversions run through ogr2ogr except TopoJSON, which GDAL reads but does not write, and which mapshaper handles instead.
Because mapshaper is a different tool, it gets its own control column so the TopoJSON results have a matching baseline. The GDAL control changes nothing in all 23 probes. The mapshaper control changes one: it flattens a GeometryCollection into separate features. That is mapshaper's own behaviour, and the TopoJSON column inherits it.
Properties are matched by name and features by a key added to each probe before conversion. Matching on position instead would have credited a format that reorders features with losing all of them, and one of them does reorder: FlatGeobuf handed back a Polygon and a MultiPolygon the other way round, which is what a format that sorts its features into a spatial index would do.
A reversed ring is recorded as a reversal. Comparing two rings position by position, when one is the other backwards, reports a shape that never moved as having moved a whole degree. Coordinate shifts are measured only where the positions came back in the same order.
Every file is written twice and the bytes compared, ignoring the path, so the reproducibility column measures the format itself. Sizes count every file a conversion produced, including the companion files a driver writes on its own.
Sizes for GeoPackage are measured with its embedded timestamp pinned, so the published figure is the same on any rerun. The reproducibility column is measured without pinning it, which is how the timestamp was found. Spatialite has no equivalent setting, so its compressed size moves by a byte or two between runs. That is the same timestamp showing up in the measurement of itself.
The data is published under CC BY 4.0 on the datasets page. The format comparison guide covers what each format is for; this page covers what each one does to your data.