{"version":"https://jsonfeed.org/version/1.1","title":"DataHacks","home_page_url":"https://datahacks.dev","feed_url":"https://datahacks.dev/feed.json","description":"Daily practical guides and hacks for common database-related tasks.","language":"en","authors":[{"name":"DataHacks","url":"https://datahacks.dev"}],"items":[{"id":"https://datahacks.dev/library/json-has-no-comments","url":"https://datahacks.dev/library/json-has-no-comments","title":"Why JSON Has No Comments and What People Do Instead","summary":"JSON has no comment syntax by design, so a // note fails in every parser tested. What people do instead: _comment keys, $comment, JSONC, JSON5 and YAML.","content_html":"<p>Open a <code>package.json</code> and try to add a note above a dependency. A line starting with <code>//</code> makes the file fail to parse, and npm tells you the JSON is invalid. Open <code>tsconfig.json</code> in the same project and the same line works, because that file is not JSON at all, it is a relative called JSONC that happens to have the same extension. The difference confuses everyone at some point, and the reason is a design decision from the early days of the format that has never been reversed: standard JSON has no comments, and every strict parser rejects them. This article explains why, confirms it with five parsers, and compares the ways people live with it, from a convention in the data to a different format altogether. The wider picture of what parsers accept is in <a href=\"/library/json-allows-parsers-quietly-accept\">what JSON allows and what parsers quietly accept</a>.</p>\n<h2>What the parsers say</h2>\n<p>The input is two lines, a comment and an object:</p>\n<pre><code class=\"language-text\">// note\n{\"a\": 1}</code></pre>\n<p>Every parser I tried rejected it, and each said so in its own words. Python raised <code>JSONDecodeError: Expecting value: line 1 column 1 (char 0)</code>, pointing at the first <code>/</code>. Node's <code>JSON.parse</code> said <code>Unexpected token '/'</code> and quoted the input as not valid JSON. PHP's <code>json_decode</code> returned an error code 4, <code>Syntax error</code>. jq 1.6 reported <code>Invalid numeric literal at line 1, column 3</code>. DuckDB's <code>json_valid</code> returned false. A comment placed after the value fails too: <code>{\"a\": 1} // note</code> produced \"Extra data\" in Python, because the parser finished the object and then found more text. Block comments, <code>/* ... */</code>, fail the same way.</p>\n<p>There is no parser setting that makes a standards-following parser accept comments, and no accident that makes it happen: the grammar has no place for them. Some tools do accept them, by design, and that is why the confusion arises: an editor that parses <code>settings.json</code> leniently will show no error, and a different program reading the same file will.</p>\n<h2>Why it was left out</h2>\n<p>Douglas Crockford, who specified the format, has explained that he took comments out of the first versions after noticing people using them to hold parsing directives, instructions to the parser that would make the same document mean different things to different readers. Since the format's value was that every parser reads the same thing, he removed the feature that could break that. Data exchange formats carry data, he argued, and a comment is not data.</p>\n<p>You can disagree with that for configuration files and many people do, and the decision is also old, widely deployed and not going to change. The grammar in RFC 8259 and ECMA-404 does not have comments, and a parser that supports them is supporting an extension.</p>\n<h2>The common workarounds</h2>\n<p>People who need notes in JSON have found several approaches, each with trade-offs.</p>\n<p><strong>A comment key.</strong> Put the note in a field: <code>{\"_comment\": \"keep in sync with the staging file\", \"timeout\": 30}</code>. It works in every parser, because it is ordinary data. The costs: the note is now part of the document, so every consumer receives it and must ignore it; a strict schema with <code>additionalProperties: false</code> rejects it; JSON has no way to attach a comment to a particular value, only to an object; and duplicate <code>_comment</code> keys in one object overwrite each other, since the last wins. Use a name that cannot collide with real fields and be consistent. Some tools define one: <code>//</code> as a key name is used informally in some package files.</p>\n<p>**JSON Schema's <code>$comment</code>.** Schemas, which are themselves JSON, have a keyword for this: <code>$comment</code> is ignored by validators and exists for human notes, from draft-07 onward. Inside a schema file, it is the sanctioned place for a comment, and unlike <code>_comment</code> it is part of the vocabulary. It does not help for data files.</p>\n<p><strong>Strip comments before parsing.</strong> A tool such as <code>jsmin</code> or a regular expression removes <code>//</code> and <code>/* */</code> and passes the rest to a real parser. It lets you keep comments in a file you author and still hand strict JSON to the consumer. The danger is the regular expression: a naive one removes <code>//</code> inside a string such as <code>\"http://example.com\"</code>, which silently corrupts a URL. Use a real comment-stripping library that understands strings, and test it on a value containing <code>//</code>.</p>\n<p><strong>Use a format that has comments.</strong> If the file is configuration that people edit, JSON was the wrong container. JSONC, \"JSON with comments\", is what VS Code uses for its settings files and what TypeScript uses for <code>tsconfig.json</code>: standard JSON plus <code>//</code> and <code>/* */</code> comments, and often tolerated trailing commas. JSON5 adds comments, trailing commas, unquoted keys, single quotes and more. YAML and TOML have comments as a first-class feature and suit configuration well. <a href=\"/library/json-json5-jsonc\">JSON versus JSON5 versus JSONC and when to use each</a> compares them.</p>\n<h2>Choosing between them</h2>\n<p>A short guide. If the document is exchanged between programs and a human rarely reads it, do not add comments: put the explanation in the documentation or the schema, which can be much longer than a note. If the file is configuration edited by hand, use a format with comments and parse it with a parser for that format, so that \"lenient\" is a contract and not an accident. If you must annotate JSON that has to remain standard, say a fixture or a feature file consumed by several tools, use a comment key with a reserved name, document it and have every consumer drop it.</p>\n<p>Avoid half-measures. A <code>.json</code> file containing comments that works in your editor and fails in CI is a trap for the next person, because the extension says one thing and the content another. Name the file by what it is (<code>.jsonc</code>, <code>.json5</code>) and let tools pick the right parser from the name. If your project uses a loader that strips comments (some bundlers and test runners do), make sure the production path uses the same one, since a config with comments read by the lenient dev loader and the strict production parser is a deployment failure waiting to happen.</p>\n<h2>Generating, not hand-writing</h2>\n<p>Many comment needs disappear if the file is generated. A script that writes JSON from a source with comments, a YAML file, a template, a spreadsheet, can keep the notes in the source and emit clean JSON. The generated file says at the top of its documentation, not its contents, that it is generated and where from. That arrangement also fixes the other editing problems of JSON, trailing commas and quoting, because nobody edits the output (<a href=\"/library/trailing-commas-json\">trailing commas in JSON and why parsers reject them</a>).</p>\n<h2>When a comment key is worse than no comment</h2>\n<p>A comment key is the wrong choice in a few cases. When the consumer is a strict schema, the key fails validation, and you have to teach the schema about it, which spreads the workaround. When the document is signed or hashed, the comment becomes part of what is signed, and a changed note changes the hash. When the file is large and machine-generated, notes in every object bloat it. And when the note is actually documentation of a field's meaning, it belongs in a schema or a README, where it can be found, versioned and translated. The grammar is in <a href=\"https://www.rfc-editor.org/rfc/rfc8259\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8259</a>, the <code>$comment</code> keyword in the <a href=\"https://json-schema.org/understanding-json-schema/reference/comments\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema reference</a>, and the JSONC convention in <a href=\"https://code.visualstudio.com/docs/languages/json#_json-with-comments\" target=\"_blank\" rel=\"noopener noreferrer\">VS Code's documentation of JSON with comments</a>.</p>","date_published":"2026-11-12T00:22:00+00:00","tags":["JSON"]},{"id":"https://datahacks.dev/library/postgresql-creating-first-database-user","url":"https://datahacks.dev/library/postgresql-creating-first-database-user","title":"Creating Your First Database User and Schema in PostgreSQL","summary":"Create your first PostgreSQL role, database and schema: CREATE ROLE with LOGIN, a database with an owner, search_path, and the public schema change.","content_html":"<p>The first time you use PostgreSQL for a project, it is tempting to connect as <code>postgres</code> and create tables. It works. It also means every program you write has the keys to the whole server, and the permission errors that would show up in production never show up on your machine. The better habit costs three statements: a role for the project, a database that role owns and, if you like, a schema inside it. This article walks through them, explains roles and schemas in the PostgreSQL sense, and covers the permission change in version 15 that surprises people who learned the older behavior. It is one of the setup steps in <a href=\"/library/setting-up-postgresql-development\">setting up PostgreSQL for development without fighting it</a> and comes after <a href=\"/library/installing-postgresql-macos-linux\">installing PostgreSQL on macOS, Linux and Windows</a>.</p>\n<h2>Roles, not users</h2>\n<p>PostgreSQL has one concept for accounts and groups: the <strong>role</strong>. A role may have the <code>LOGIN</code> attribute (so it can connect), may own objects, may have privileges and may be a member of other roles. <code>CREATE USER</code> is an alias for <code>CREATE ROLE ... LOGIN</code>, and <code>CREATE GROUP</code> is an alias for <code>CREATE ROLE</code> without login. You will see all three in tutorials; they create the same thing with different defaults.</p>\n<p>A superuser role bypasses all permission checks. The role named <code>postgres</code> is a superuser created at installation. Use superuser roles for administration only.</p>\n<h2>Connect as the superuser</h2>\n<p>You need an account that can create roles. Depending on the platform, that is <code>sudo -u postgres psql</code> (Debian, Red Hat family), <code>psql postgres</code> (Homebrew, where your own account is the superuser) or the account and password you chose in the Windows installer or the <code>POSTGRES_USER</code> of a Docker container.</p>\n<pre><code class=\"language-bash\">sudo -u postgres psql</code></pre>\n<p>At the <code>postgres=#</code> prompt you can create roles. The <code>#</code> signals a superuser; an ordinary role shows <code>&gt;</code>.</p>\n<h2>Create the role</h2>\n<pre><code class=\"language-sql\">CREATE ROLE shop LOGIN PASSWORD 'dev-only-password';</code></pre>\n<p><code>LOGIN</code> allows connections. The password is stored as a hash (SCRAM-SHA-256 by default in current versions). Writing the password in a statement puts it in your psql history and possibly in the server log if statement logging is on. To keep it out, create the role without a password and set it with the <code>\\password</code> meta-command, which prompts and sends the hash:</p>\n<pre><code class=\"language-sql\">CREATE ROLE shop LOGIN;\n\\password shop</code></pre>\n<p>Useful attributes at creation: <code>CREATEDB</code> lets the role create databases (handy for a test runner), <code>CREATEROLE</code> lets it create other roles (avoid for application roles), <code>NOSUPERUSER</code> is the default, <code>CONNECTION LIMIT 20</code> caps simultaneous sessions, and <code>VALID UNTIL '2027-01-01'</code> makes a password expire. Do not give application roles <code>SUPERUSER</code>.</p>\n<p>List roles with <code>\\du</code>:</p>\n<pre><code class=\"language-text\">                                   List of roles\n Role name |                         Attributes\n-----------+------------------------------------------------------------\n postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS\n shop      |</code></pre>\n<h2>Create the database</h2>\n<pre><code class=\"language-sql\">CREATE DATABASE shop OWNER shop;</code></pre>\n<p>The owner has full control of the database's contents and can drop it. One database per project is the norm, and it isolates data and settings. Clients connect to a specific database, and a connection cannot query across databases without extra tooling.</p>\n<p>You can add <code>ENCODING 'UTF8'</code> and a locale; the defaults from <code>template1</code> are usually right. Check with <code>\\l</code>:</p>\n<pre><code class=\"language-text\">                                 List of databases\n   Name    |  Owner   | Encoding | Locale Provider |   Collate   |   Ctype\n-----------+----------+----------+-----------------+-------------+-------------\n postgres  | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8\n shop      | shop     | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8</code></pre>\n<h2>Connect as the new role</h2>\n<p>Leave the superuser session (<code>\\q</code>) and connect as the project role.</p>\n<pre><code class=\"language-bash\">psql -h localhost -U shop -d shop</code></pre>\n<p>Note the <code>-h localhost</code>. Without it, psql uses the local socket, where Debian and Red Hat family systems apply peer authentication by default and will reject the role <code>shop</code> unless your operating system user is also <code>shop</code> (<a href=\"/library/postgresql-fixing-peer-authentication-failed-user\">fixing peer authentication failed for user postgres</a> explains why). Over TCP, the password applies.</p>\n<p>Check who you are and where:</p>\n<pre><code class=\"language-sql\">SELECT current_user, current_database(), current_schema();</code></pre>\n<h2>Schemas</h2>\n<p>A schema is a namespace inside a database: a container for tables, views, functions and types. The same table name can exist in two schemas. Every database starts with a schema called <code>public</code>. Tables are referred to as <code>schema.table</code>, and unqualified names are resolved through the <strong>search path</strong>.</p>\n<pre><code class=\"language-sql\">SHOW search_path;\n-- \"$user\", public</code></pre>\n<p>The default search path first looks for a schema named after the current role (<code>shop</code>), then <code>public</code>. If a schema <code>shop</code> exists, unqualified <code>CREATE TABLE orders</code> creates the table there, because it is the first schema in the path that exists.</p>\n<p>Create a schema owned by the project role:</p>\n<pre><code class=\"language-sql\">CREATE SCHEMA app AUTHORIZATION shop;\nALTER ROLE shop SET search_path = app, public;</code></pre>\n<p>Reconnect for the setting to apply (role-level settings apply to new sessions). Now <code>CREATE TABLE orders (...)</code> in a <code>shop</code> session goes into <code>app</code>. Using a named schema keeps your objects apart from extensions' objects in <code>public</code> and gives you something to grant permissions on. For a single-application database, <code>public</code> is fine, with the caveat below.</p>\n<h2>The change in PostgreSQL 15</h2>\n<p>Before version 15, every role could create tables in the <code>public</code> schema of any database it could connect to. That was convenient and a security weakness: a shared database invited objects from anyone. From version 15 the privilege is removed: <code>CREATE</code> on <code>public</code> is no longer granted to <code>PUBLIC</code> (everyone), and the database owner owns <code>public</code>.</p>\n<p>The consequence for beginners: if you create a role, connect to a database you do not own, and run <code>CREATE TABLE t (...)</code>, you see:</p>\n<pre><code class=\"language-text\">ERROR:  permission denied for schema public</code></pre>\n<p>Three ways to resolve it. Make the role the owner of the database (so it owns <code>public</code>, as in <code>CREATE DATABASE shop OWNER shop</code>). Create the role its own schema and use that. Or, as a superuser or the owner, grant the privilege deliberately:</p>\n<pre><code class=\"language-sql\">GRANT USAGE, CREATE ON SCHEMA public TO shop;</code></pre>\n<p>Prefer the first two. If you upgraded a database from an older version, the old permissive grant may still be in place; check with <code>\\dn+</code>.</p>\n<h2>Grant access to other roles</h2>\n<p>Roles can read the project's data without owning it. A read-only role for reporting:</p>\n<pre><code class=\"language-sql\">CREATE ROLE reporting LOGIN;\nGRANT CONNECT ON DATABASE shop TO reporting;\nGRANT USAGE ON SCHEMA app TO reporting;\nGRANT SELECT ON ALL TABLES IN SCHEMA app TO reporting;\nALTER DEFAULT PRIVILEGES FOR ROLE shop IN SCHEMA app GRANT SELECT ON TABLES TO reporting;</code></pre>\n<p>The first three give the role entry; <code>GRANT SELECT ON ALL TABLES</code> covers tables that exist now; <code>ALTER DEFAULT PRIVILEGES</code> covers tables the owner creates later, which is the step people forget. Check privileges with <code>\\dp</code> (table privileges) and <code>\\ddp</code> (default privileges).</p>\n<h2>Production-shaped separation</h2>\n<p>In production, one role often owns the schema and runs migrations, and a different role with narrower rights serves the application: it can read and write rows but cannot alter tables. Rehearse that in development:</p>\n<pre><code class=\"language-sql\">CREATE ROLE shop_migrator LOGIN;\nCREATE ROLE shop_app LOGIN;\nALTER SCHEMA app OWNER TO shop_migrator;\nGRANT USAGE ON SCHEMA app TO shop_app;\nGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO shop_app;\nALTER DEFAULT PRIVILEGES FOR ROLE shop_migrator IN SCHEMA app\n  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO shop_app;\nGRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO shop_app;</code></pre>\n<p>Running the application as the narrow role locally catches the \"permission denied for table\" error before a deployment does.</p>\n<h2>Dropping and cleaning up</h2>\n<p>To remove a development setup: drop the database first, then the role. A role cannot be dropped while it owns objects or holds privileges; <code>DROP OWNED BY shop;</code> removes its objects and revokes its grants in the current database, then <code>DROP ROLE shop;</code>.</p>\n<pre><code class=\"language-sql\">DROP DATABASE IF EXISTS shop;\nDROP ROLE IF EXISTS shop;</code></pre>\n<p><code>DROP DATABASE</code> fails if someone is connected; terminate sessions with <code>SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'shop';</code> or, in recent versions, <code>DROP DATABASE shop WITH (FORCE);</code>.</p>\n<h2>Common mistakes</h2>\n<p><strong>Creating the database as the superuser without an owner.</strong> The superuser then owns it, and your role cannot create tables. Add <code>OWNER</code>, or <code>ALTER DATABASE shop OWNER TO shop;</code>.</p>\n<p><strong>Case and quoting.</strong> <code>CREATE ROLE Shop</code> creates <code>shop</code>, since unquoted identifiers fold to lowercase; <code>CREATE ROLE \"Shop\"</code> creates a different, case-sensitive role that you must quote forever. Use lowercase names.</p>\n<p><strong>Reserved words.</strong> A role or table called <code>user</code> or <code>order</code> needs quoting. Avoid them.</p>\n<p><strong>Passwords in scripts.</strong> Fine for disposable local roles; never reuse them anywhere that matters.</p>\n<h2>When one role is not enough</h2>\n<p>One role is enough for a personal project or a prototype. It stops being enough when more than one application or person touches the database, when you separate migrations from runtime or when you give analysts read-only access. Then use several roles and group roles (roles with <code>NOLOGIN</code> that hold privileges, granted to login roles with <code>GRANT group TO user</code>). For the full model, the manual's chapters on <a href=\"https://www.postgresql.org/docs/current/user-manag.html\" target=\"_blank\" rel=\"noopener noreferrer\">database roles</a>, <a href=\"https://www.postgresql.org/docs/current/ddl-priv.html\" target=\"_blank\" rel=\"noopener noreferrer\">privileges</a> and <a href=\"https://www.postgresql.org/docs/current/ddl-schemas.html\" target=\"_blank\" rel=\"noopener noreferrer\">schemas</a> are the reference, and <a href=\"/library/connecting-postgresql-command-line\">connecting to PostgreSQL from the command line with psql</a> shows how to use the new role.</p>","date_published":"2026-11-11T15:20:00+00:00","tags":["PostgreSQL"]},{"id":"https://datahacks.dev/library/handling-csv-files-too-big","url":"https://datahacks.dev/library/handling-csv-files-too-big","title":"Handling CSV Files Too Big to Open in Excel or Python","summary":"A decision guide for CSV files too big to open: look, count, sample, query in place, chunk, split, compress or convert, with numbers from a 1.3 GB file.","content_html":"<p>A file arrives that is too large to open. The spreadsheet freezes, or the notebook is killed, or the editor gives up. The reflex is to find a bigger machine or a smarter way to open it. The better move is to stop trying to open it. A large CSV is a table in a text file, and you can ask it questions without ever holding it in memory. This guide is a decision path: what to do first, how to know what is in the file, which tool to use for which question, and what each costs, with numbers measured on one 1.3 GB, 20,000,000-row file. The articles linked from each section go into detail.</p>\n<h2>First, do not open it</h2>\n<p>Opening a file means loading it. A text editor builds a line index, a spreadsheet allocates a cell for every field, pandas builds a column for every column. For a file the size of your memory, each of those fails differently: slowly, with a limit message, or with a killed process. The alternative is <strong>streaming</strong>: tools that read the file once, in order, keeping a little state, and print an answer. Almost everything you want to know about a big CSV can be had that way.</p>\n<p>The steps below go from cheap to expensive. Stop at the first one that answers your question.</p>\n<h2>Step 1: look at the start</h2>\n<pre><code class=\"language-bash\">head -n 5 orders.csv\nfile orders.csv        # encoding, line endings\nls -l orders.csv       # the size</code></pre>\n<p>Five lines show you the header, the delimiter, the quoting, the kind of values and the line endings. <code>head</code> reads only the start, so it takes no time on any size of file. If the first lines look wrong (a title, a blank line, a BOM, <code>;</code> as the delimiter), that is worth knowing before any tool is chosen; see the <a href=\"/library/tell-delimiter-unknown-csv\">delimiter</a> and <a href=\"/library/detect-encoding-csv\">encoding</a> articles. The <a href=\"/library/previewing-csv-terminal-column\">terminal preview article</a> covers <code>column</code>, <code>csvlook</code> and friends.</p>\n<h2>Step 2: count what is in it</h2>\n<p>How many rows? The cheap answer is <code>wc -l</code>, and on this file it was wrong:</p>\n<table><thead><tr><th>Tool</th><th>Answer</th><th>Time</th></tr></thead><tbody><tr><td><code>wc -l</code></td><td>20,009,857 lines</td><td>0.83 s</td></tr><tr><td>DuckDB <code>count(*)</code></td><td>20,000,000 records</td><td>0.99 s</td></tr><tr><td>qsv <code>count</code></td><td>20,000,000 records</td><td>2.20 s</td></tr><tr><td>Python <code>csv.reader</code></td><td>20,000,001 (with header)</td><td>14.74 s</td></tr></tbody></table>\n<p>The 9,856-line difference is the number of records whose note contains a line break: a quoted field can span lines, and <code>wc</code> counts newlines. The <a href=\"/library/counting-lines-multi-gigabyte-csv\">counting article</a> has the full table. The practical rule: use a parser to count records, and use <code>wc -l</code> only to detect that something is odd (if lines minus one differs from the record count, there are multi-line fields, and every line-based tool below needs care). An estimate from file size over average row size is nearly free, but sample from several places in the file: the first 100,000 rows overestimated the count by 4%, 20 random places by 0.26%.</p>\n<p>Also check the last byte (<code>tail -c 1</code>): a file that does not end with a newline was cut off or is still being written (<a href=\"/library/testing-csv-export-truncated-last\">truncation checks</a>, <a href=\"/library/tailing-following-csv-still\">following a growing file</a>).</p>\n<h2>Step 3: ask the question where the data is</h2>\n<p>The strongest tool for \"a large CSV and a question\" is a SQL engine that reads the file directly. DuckDB:</p>\n<pre><code class=\"language-sql\">SELECT status, count(*) AS orders, round(sum(amount), 2) AS revenue\nFROM read_csv('orders.csv')\nGROUP BY 1 ORDER BY 1;</code></pre>\n<p>That grouped the 20,000,000 rows in <strong>1.23 s with a 718,120 KB peak</strong>, without creating a table or a copy. The reader is multi-threaded and converts only the columns the query uses. Any question that is a filter, an aggregate, a join with a small table or a sort of a small result fits. The <a href=\"/library/querying-csv-files-duckdb-instead\">DuckDB articles</a> cover the options, including types and bad rows.</p>\n<p>The same job in pandas, loading everything:</p>\n<pre><code class=\"language-python\">df = pd.read_csv(\"orders.csv\")\ndf.groupby(\"status\")[\"amount\"].agg([\"count\", \"sum\"])</code></pre>\n<p>took 5.55 s and a <strong>3,924,400 KB peak</strong> (3.39 s and 3.9 GB on a second run) just for the load, about three times the file. On a machine with 16 GB that is fine; on one with 4 GB the file would not load. It also read the five-digit <code>zip</code> column as <code>int64</code>, so <code>09587</code> became 9587. The <a href=\"/library/much-memory-pandas-needs-load\">memory article</a> explains the sizes and how to estimate them before loading.</p>\n<h2>Step 4: read less, or read in chunks</h2>\n<p>If you want pandas, shrink what it holds. Read only the columns you need and give them compact types:</p>\n<pre><code class=\"language-python\">df = pd.read_csv(\"orders.csv\", usecols=[\"status\", \"amount\"],\n                 dtype={\"status\": \"category\", \"amount\": \"float64\"})</code></pre>\n<p>Two columns held 0.18 GB with a 459,744 KB peak. If even that does not fit, aggregate in chunks:</p>\n<pre><code class=\"language-python\">total = None\nfor chunk in pd.read_csv(\"orders.csv\", usecols=[\"status\", \"amount\"], chunksize=1_000_000):\n    part = chunk.groupby(\"status\")[\"amount\"].agg([\"count\", \"sum\"])\n    total = part if total is None else total.add(part, fill_value=0)</code></pre>\n<p>That took 6.21 s with a <strong>150,740 KB peak</strong> and returned the same counts as DuckDB. It works for sums, counts, minima and maxima; a mean needs the sum and the count, and a median does not chunk. Three traps from the <a href=\"/library/reading-large-csv-chunks-pandas\">chunking article</a>: types are inferred per chunk unless you pass <code>dtype</code>, identifiers lose leading zeros, and the pyarrow engine failed on this file because of the multi-line fields.</p>\n<h2>Step 5: take a sample</h2>\n<p>For exploration you want a few thousand rows, not a table. Five ways, from the <a href=\"/library/sampling-random-rows-huge-csv\">sampling article</a>:</p>\n<ul><li><p><code>shuf -n 1000</code> in 4.39 s using 2.7 MB, with <code>--random-source</code> to make it repeatable;</p></li><li><p>an awk reservoir in 5.05 s;</p></li><li><p>a seek to random byte offsets in 0.08 s, which favors rows after long lines;</p></li><li><p>DuckDB's <code>USING SAMPLE reservoir(1000 ROWS) REPEATABLE (42)</code> in 4.69 s;</p></li><li><p>a record-aware Python reservoir in 26.83 s.</p></li></ul>\n<p>Only the DuckDB and Python versions return whole records. In a 100,000-line sample from <code>shuf</code> and from the seek script, 45 to 67 lines were halves of a row with a line break, and neither sample parsed as CSV. A sample is a view of the file, and the sampling method is part of the result.</p>\n<h2>Step 6: split it, if you must</h2>\n<p>Sometimes the consumer cannot take the size: a spreadsheet, an upload form, a loader. Then split, with a tool that matches the need (<a href=\"/library/splitting-large-csv-smaller-files\">splitting article</a>):</p>\n<table><thead><tr><th>Tool</th><th>Time</th><th>Header in each part</th><th>Note</th></tr></thead><tbody><tr><td><code>split -l 1000000</code></td><td>3.64 s</td><td>no (first part only)</td><td>byte-exact; line-based</td></tr><tr><td>awk</td><td>22.83 s</td><td>yes</td><td>byte-exact; line-based</td></tr><tr><td>qsv split</td><td>6.79 s</td><td>yes</td><td>record-aware; re-serializes</td></tr><tr><td>Miller split</td><td>35.48 s</td><td>yes</td><td>record-aware; re-serializes</td></tr><tr><td>DuckDB <code>FILE_SIZE_BYTES '100MB'</code></td><td>4.74 s</td><td>yes</td><td>splits by size</td></tr></tbody></table>\n<p>A cut that lands inside a quoted field breaks two parts, and a lenient reader like DuckDB reads the broken half as a row without complaint. Verify any split with a strict parser and a total record count. For Excel the target is under 1,048,576 rows including the header, though the better move is usually an extract or a summary (<a href=\"/library/opening-csv-file-too-large\">the Excel limits article</a>).</p>\n<h2>Step 7: compress it</h2>\n<p>A CSV compresses to 25 to 35% of its size. On a 130 MB export, <code>gzip -6</code> gave 42.2 MB in 4.6 s, <code>zstd -3</code> 43.7 MB in about the time of <code>gzip -1</code>, and <code>zstd -19</code> 35.4 MB in 58.8 s. You rarely need to unzip: <code>zcat file.gz | head</code>, <code>pd.read_csv(\"file.csv.gz\")</code> and DuckDB's <code>read_csv</code> read compressed files directly. The cost is that a compressed stream has no random access, so compress in parts if readers want slices. Details and the truncated-archive trap are in the <a href=\"/library/compressing-csv-files-reading-them\">compression article</a> and the <a href=\"/library/exporting-csv-database-straight\">gzip export article</a>.</p>\n<h2>Step 8: convert it, if it will be read again</h2>\n<p>If the same file will be queried many times, pay the parse cost once. Convert to Parquet:</p>\n<pre><code class=\"language-sql\">COPY (SELECT * FROM read_csv('orders.csv')) TO 'orders.parquet' (FORMAT parquet, COMPRESSION zstd);</code></pre>\n<p>The 130 MB, 2,000,000-row sample file became 34.2 MB in under a second, and a one-column maximum took 0.036 s instead of 0.203 s (<a href=\"/library/parquet-beats-csv-repeated-analysis\">why Parquet wins</a>, <a href=\"/library/converting-csv-parquet\">the conversion</a>). Decide the types when you convert: DuckDB read the amount as a <code>DOUBLE</code>, and a <code>DECIMAL(10,2)</code> keeps <code>103.10</code>. Alternatively load into a database; PostgreSQL's <code>COPY</code> loaded 2,000,000 rows in 3.3 s (<a href=\"/library/load-csv-file-postgresql\">loading</a>). A <a href=\"/library/benchmarking-csv-loading-pandas-duckdb\">benchmark with the caveats spelled out</a> compares the options.</p>\n<h2>The tools, one by one</h2>\n<p>**<code>head</code>, <code>tail</code>, <code>wc</code>, <code>grep</code>, <code>cut</code>, <code>awk</code>.** The Unix tools read a line at a time, use almost no memory and start instantly. They are right for looking, for counting lines, for filtering on a simple pattern and for cutting columns, as long as no field contains a comma or a line break that matters. Their weakness is that they do not understand CSV: <code>awk -F,</code> splits <code>\"gift, wrap\"</code> into two fields, and <code>wc -l</code> counts a two-line note twice. Use them to explore and to check; do not build a pipeline on them until you have confirmed the file is simple.</p>\n<p><strong>csvkit, qsv and Miller.</strong> These are CSV-aware command-line tools. They parse quotes properly, can select, filter, sort, join and summarize, and in the tests they were correct on the multi-line fields. qsv counted the 20,000,000 records in 2.20 s and split the file in 6.79 s; Miller was the slowest splitter at 35.48 s; csvkit's <code>csvjson</code> took 36 s with type inference on. Two cautions: they infer types unless told not to (Miller wrote <code>+14192381408</code> as a number), and they re-serialize, so the output is the same records but not the same bytes.</p>\n<p><strong>DuckDB.</strong> A SQL engine in a single binary or a Python package. It reads CSV, compressed CSV and Parquet in place, runs on all cores, spills to disk and writes CSV, Parquet and JSON. It was the fastest tool for counting records (0.99 s) and for the grouped query (1.23 s), and it is the one I reach for first. Its leniency is the thing to watch: with default settings it read half of a split record as a row without a warning. Declare columns and use strict options when the file matters.</p>\n<p><strong>pandas.</strong> The right tool when the next step is Python: reshaping, modeling, plotting. It is not a streaming tool, but with <code>usecols</code>, <code>dtype</code> and <code>chunksize</code> it stays small. The default load made a zip an <code>int64</code>, and the pyarrow engine failed on a file with line breaks in fields, so read the defaults before trusting them.</p>\n<p><strong>PostgreSQL and MySQL.</strong> Right when the data will be used by an application, needs indexes or constraints, or has to be shared. <code>COPY</code> loaded 2,000,000 rows in 3.3 s. Loading is a project, not an afterthought, and the <a href=\"/library/load-csv-file-postgresql\">loading guide</a> lists the failure messages you will meet. For analysis of a file you will look at once, a database is more ceremony than a query engine.</p>\n<p><strong>Excel and Sheets.</strong> For summaries and small extracts. They are limited to 1,048,576 rows in Excel's case, and they convert values when they open a file (zip codes, long numbers, anything that looks like a date), so import with text columns and never use them to open a file you are going to write back.</p>\n<p><strong>Parquet and the tools around it.</strong> Not a tool but a format, and the most effective single change for a file that is queried repeatedly. Convert once, with types decided, and every later query is cheaper. The cost is that the file is no longer something you can read in an editor.</p>\n<h2>By the size of the file</h2>\n<p>A rough map, from experience with the tests here and not a law.</p>\n<p>Under about 100 MB, most things work, and the choice is about convenience. Open it in pandas or a spreadsheet if it has fewer than a million rows, and check for line breaks and encodings first. From 100 MB to a few gigabytes, loading everything into pandas starts to depend on memory: estimate it (about three times the file for the peak), type the columns, or switch to DuckDB. From a few gigabytes to a few hundred, stop thinking about loading: query in place, sample for exploration, compress for storage and convert to Parquet for repeated work. Beyond that, the file is better in a database or a columnar store, split by a meaningful key such as day, so that a query reads only the part it needs (<a href=\"/library/splitting-large-csv-smaller-files\">partitioning with <code>PARTITION_BY</code></a> wrote seven country directories from the test file in 12.17 s).</p>\n<p>None of these boundaries is sharp. A file of two long text columns is heavier than the same size of integers, and a wide file with fifty columns costs more per row than a narrow one. Let a small experiment on a slice of your data choose, as the <a href=\"/library/benchmarking-csv-loading-pandas-duckdb\">benchmarking article</a> describes.</p>\n<h2>Which tool for which question</h2>\n<table><thead><tr><th>You want to...</th><th>Use</th></tr></thead><tbody><tr><td>See what is in the file</td><td><code>head</code>, <code>file</code>, <code>tail -c 1</code></td></tr><tr><td>Count records</td><td>DuckDB or qsv, not <code>wc -l</code></td></tr><tr><td>Answer an aggregate or a filter</td><td>DuckDB over the CSV</td></tr><tr><td>Explore a few rows</td><td>a DuckDB or Python sample</td></tr><tr><td>Use pandas anyway</td><td><code>usecols</code>, <code>dtype</code>, chunks</td></tr><tr><td>Hand it to a spreadsheet</td><td>an extract, a summary, or parts under 1,000,000 rows</td></tr><tr><td>Keep or move it</td><td>gzip or zstd</td></tr><tr><td>Query it repeatedly</td><td>Parquet, or a database</td></tr><tr><td>Watch it grow</td><td><code>tail -F</code>, with care for partial lines</td></tr></tbody></table>\n<h2>A worked example: from \"too big\" to an answer</h2>\n<p>Say a colleague sends <code>orders-2025.csv</code>, 1.3 GB, and asks how much revenue came from refunded orders in Brazil, by month. A sensible session runs like this, and the whole thing takes minutes.</p>\n<p>First, <code>ls -l</code> and <code>head -n 5</code>: the size is 1.3 GB, the header has eight columns, <code>created_at</code> is an ISO timestamp, <code>amount</code> has two decimals, the delimiter is a comma. Second, a record count with DuckDB, <code>SELECT count(*) FROM read_csv('orders-2025.csv')</code>, about a second, and <code>20000000</code>. That is far over a spreadsheet's limit and fine for a query engine. Third, a check that nothing is odd: the last byte is a newline (<code>tail -c 1</code>), <code>wc -l</code> is 9,856 higher than the count (so there are multi-line fields; note it), and <code>DESCRIBE</code> shows the types DuckDB inferred. Fourth, the question itself:</p>\n<pre><code class=\"language-sql\">SELECT date_trunc('month', created_at) AS month, round(sum(amount), 2) AS revenue\nFROM read_csv('orders-2025.csv')\nWHERE status = 'refunded' AND country = 'BR'\nGROUP BY 1 ORDER BY 1;</code></pre>\n<p>Twelve rows come back in about a second, and they go into a message or a spreadsheet. If the colleague then asks \"and which customers?\", the same file answers the next query in another second, because the engine streams. If the questions keep coming, convert once to Parquet and ask the next fifty against that. At no point did anything need the whole file in memory, and at no point did anyone open it.</p>\n<p>The mistakes to avoid in this session are small ones. Do not trust <code>wc -l</code> for the count. Do not let a tool guess that the zip is a number if you are going to join on it. Do not paste the twelve numbers without saying which file and query produced them; keep the query next to the result. And if the answer will be used for something that matters, run it a second way, with a different tool, and compare.</p>\n<h2>Budgeting memory and time</h2>\n<p>Before you start, decide how much you can afford. A default pandas load needs about three times the file size at peak and holds one and a half times; typed and trimmed it can hold a third of the file. DuckDB over a CSV used 718,120 KB for a grouped query on the 1.3 GB file and uses more for large joins and sorts, spilling to disk when it must. A chunked loop needs about the chunk size times the width of the columns you keep, and 150 MB was enough here. If the budget is tight, rank the tools by their peaks in this guide rather than by their speeds.</p>\n<p>Time works the same way. Counting is a pass over the file, about a second per gigabyte for a good parser on a warm cache and several seconds from a cold disk. A query is one pass or a few; a conversion to Parquet is one pass plus a write. If you expect to read the file more than a handful of times, a pass per question adds up, and the conversion repays its cost. If you expect to read it once, do not convert. The <a href=\"/library/benchmarking-csv-loading-pandas-duckdb\">loading benchmark</a> lists what to hold constant when you compare.</p>\n<h2>Scenarios and what I would do</h2>\n<p><strong>A vendor sends a 3 GB file every Monday.</strong> Do not open it by hand. Write a short script that checks the size and last byte, counts records with DuckDB, validates the header and a few columns (<a href=\"/library/validate-csv-file\">validation guide</a>), and writes the files you actually need in Parquet. Keep the original compressed. Compare the counts week over week; a file that is half the usual size is a failed export.</p>\n<p><strong>A one-off 50 GB dump on a laptop with 8 GB.</strong> Stay with streaming tools. DuckDB spills to disk for large aggregations; if it complains, reduce the question or process in key ranges. Sample first to find the columns that matter, then query only those. Do not decompress to disk if you can read the compressed file in place.</p>\n<p><strong>A file that must go into Excel.</strong> It is the summary or the extract that goes in, not the file. Decide with the recipient what they are going to do with it, produce that, and name it for what it holds (<a href=\"/library/opening-csv-file-too-large\">the Excel article</a>).</p>\n<p><strong>A file that is still being written.</strong> Follow it with <code>tail -F</code> and act only on complete lines (<a href=\"/library/tailing-following-csv-still\">following a CSV</a>); do not count or load it until the writer is done, or take a snapshot copy first.</p>\n<p><strong>A file that will not parse.</strong> Check the structure before blaming the tool: delimiter, quoting, ragged rows and encoding. The <a href=\"/library/checking-row-column-counts-csv\">counting rows and columns article</a> and the <a href=\"/library/repeatable-way-clean-messy-csv\">cleaning hub</a> cover what to do about it.</p>\n<h2>Habits that scale</h2>\n<p><strong>Keep the source immutable.</strong> Every derived file (sample, part, Parquet, extract) should be rebuildable from the original with a command you saved. Store a checksum of the original and note it in the derived file's name or manifest.</p>\n<p><strong>Name derived files for what they are.</strong> <code>orders-br-march.csv</code>, <code>orders-sample-1000-seed42.csv</code>, <code>orders.parquet</code> made from <code>orders-2025.csv</code> on a given date. A sample that looks like the real thing is a hazard.</p>\n<p><strong>Prefer parsers to line tools when the data is unknown.</strong> Line tools are fast and correct only when no field contains a line break. Check once, and write the answer down.</p>\n<p><strong>Say what the types are.</strong> Identifiers are text, money is a decimal, dates are dates. Inference is for exploring and not for pipelines.</p>\n<p><strong>Verify in a second way.</strong> A count from <code>wc</code> and a count from a parser, a sum from DuckDB and a sum from pandas, a record found by <code>grep</code> and in the output. Agreement between two methods is cheap evidence, and disagreement is worth an hour.</p>\n<p><strong>Measure, then move on.</strong> Most of the time, the first streaming query answers the question. The elaborate machinery (chunks, partitions, conversions) is for the cases in which it does not, and the numbers above tell you roughly when that is.</p>\n<h2>What goes wrong in every approach</h2>\n<p>Four hazards recur, and each of the spokes found at least one.</p>\n<p><strong>Line breaks inside fields.</strong> They make <code>wc -l</code> wrong, <code>split -l</code>, <code>shuf</code> and a seek sample unsafe, and a naive follower fragile. Find out early whether the file has any; if it does, use parser-aware tools, or have the producer remove them.</p>\n<p><strong>Type inference.</strong> Defaults turn identifiers into numbers, dates into strings and decimals into floats. Say what each column is.</p>\n<p><strong>Silent leniency.</strong> DuckDB read half a record as a row, pandas padded a short row, XlsxWriter dropped rows past the limit with a return code. Verify with a strict parser or a second tool.</p>\n<p><strong>The cache.</strong> All the timings here are warm. On a cold disk or a network share, the I/O dominates and the ranking of tools compresses.</p>\n<h2>A short glossary of the failure modes</h2>\n<p>It helps to name the problems, because the same few appear in every article of this cluster.</p>\n<p><em>Peak versus resident memory.</em> A load needs more memory while it runs than the result occupies afterwards. Plan for the peak, which is what the operating system or the scheduler enforces.</p>\n<p><em>Line versus record.</em> A line ends at a newline byte. A record ends at a newline that is not inside quotes. The two differ by the number of multi-line fields, and a tool that counts one when you wanted the other gives a plausible wrong answer.</p>\n<p><em>Streaming versus materializing.</em> A streaming tool keeps a bounded amount of state and passes over the data once. A materializing tool builds the whole table. The first scales with time, the second with memory.</p>\n<p><em>Cache.</em> A repeated run on the same file is quicker because the operating system keeps the bytes in memory. A benchmark that does not say which it measured is half a benchmark.</p>\n<p><em>Leniency.</em> Many readers accept input that a strict parser rejects, by padding, skipping, truncating or guessing. Leniency is helpful for exploration and harmful for validation, and a reader that never complains is not evidence of a clean file.</p>\n<p><em>Derived artifact.</em> A sample, a part, a Parquet file or an extract made from the source. It carries the source's checksum or name in your notes, and it can be thrown away and rebuilt.</p>\n<p>With those six words, most conversations about \"the file is too big\" become specific enough to solve: is it the peak, the lines, the streaming, the cache, the leniency or the artifact?</p>\n<h2>How to tell it worked</h2>\n<p>Whatever you did, check the result against the source in a second way: a count from a different tool, a total you know, a record you can find by eye. For derived files (parts, samples, Parquet), check the row count and a checksum of the content. Keep the commands. The <a href=\"/library/validate-csv-file\">validation guide</a> has the checks to run on the file before you trust it at all.</p>\n<h2>When the file is not the problem</h2>\n<p>Sometimes the size is a symptom. A file that is 20 GB because every row repeats a long text column could be a table plus a lookup. A daily full export that nobody diffs could be an incremental one. A request to \"open the whole file\" could be a request for three numbers. Ask what the reader will do with it before you pick a tool. And if the data lives in a database, query the database: it can do what these tools do without the export, and the export is where line breaks, types and encodings get damaged in the first place (<a href=\"/library/exporting-query-results-csv-every\">exporting from databases</a>).</p>\n<p>Further reading: <a href=\"https://duckdb.org/docs/2.0/data/csv/overview\" target=\"_blank\" rel=\"noopener noreferrer\">DuckDB CSV import</a>; <a href=\"https://pandas.pydata.org/docs/user_guide/scale.html\" target=\"_blank\" rel=\"noopener noreferrer\">pandas scaling to large datasets</a>; <a href=\"https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3\" target=\"_blank\" rel=\"noopener noreferrer\">Excel specifications and limits</a>.</p>","date_published":"2026-11-11T00:22:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/well-formed-valid-xml","url":"https://datahacks.dev/library/well-formed-valid-xml","title":"Well Formed Versus Valid XML and the Difference in Practice","summary":"Well formed means the syntax parses, valid means it follows a DTD or XSD. Ten broken files and two schemas run through xmllint show what each catches.","content_html":"<p>XML has two levels of correctness, and most of the confusion about it comes from mixing them up. A document can be <strong>well formed</strong>: it follows the grammar of XML itself, so a parser can read it into a tree. And it can be <strong>valid</strong>: it also follows a set of rules for one particular kind of document, written in a DTD or an XML Schema. Every valid document is well formed, and most well-formed documents are not valid for anything, because nobody has written the rules.</p>\n<p>I ran a batch of small broken files through <code>xmllint</code> to see what each level catches, and what neither does.</p>\n<h2>Well formed: the syntax rules</h2>\n<p>A parser has to refuse a document that breaks the grammar, because it cannot build a tree from it. The rules are short, and each one has a failure message. Ten files, each broken in one way, with the first line <code>xmllint --noout</code> printed:</p>\n<pre><code class=\"language-text\">unclosed         Opening and ending tag mismatch: b line 1 and a            &lt;a&gt;&lt;b&gt;t&lt;/a&gt;\ntwo-roots        Extra content at the end of the document                   &lt;a/&gt;&lt;b/&gt;\nunquoted-attr    AttValue: \" or ' expected                                  &lt;a x=1/&gt;\ndup-attr         Attribute x redefined                                      &lt;a x=\"1\" x=\"2\"/&gt;\namp              xmlParseEntityRef: no name                                 &lt;a&gt;Tom &amp; Jerry&lt;/a&gt;\nlt-in-text       StartTag: invalid element name                             &lt;a&gt;1 &lt; 2&lt;/a&gt;\ncase             Opening and ending tag mismatch: B line 1 and b            &lt;a&gt;&lt;B&gt;x&lt;/b&gt;&lt;/a&gt;\nno-root          Document is empty                                          (empty file)\ntext-before      Start tag expected, '&lt;' not found                          text&lt;a/&gt;\nspace-before-decl XML declaration allowed only at the start of the document  (space, then &lt;?xml ...?&gt;)</code></pre>\n<p>The rules that these reflect: tags must be closed and nested properly, there is exactly one root element, attribute values are quoted, attribute names are unique within an element, the characters <code>&amp;</code> and <code>&lt;</code> are reserved in text, names are case sensitive, and the XML declaration, if present, is the very first thing in the file with nothing before it. Not one of these errors is about meaning. They are about whether a tree can be built.</p>\n<p>That last rule is the one that surprises people most. A single space or a byte order mark in the wrong place breaks a file that looks fine (<a href=\"/library/xml-content-not-allowed-prolog\">content is not allowed in prolog</a> explains the usual cause).</p>\n<p>Programming-language parsers apply these rules and nothing more. Python's <code>ElementTree</code> and <code>lxml</code> both refused all ten files (<code>ParseError</code> and <code>XMLSyntaxError</code>) and both accepted the good file and the one that breaks the DTD, whatever the elements are called. Browsers and the XML parsers of other languages follow the same specification.</p>\n<h2>Valid: the rules for one kind of document</h2>\n<p>A well-formed catalog can still be nonsense for the system that reads it. My example, a book with no author and a price in a currency the shop does not support:</p>\n<pre><code class=\"language-xml\">&lt;catalog&gt;&lt;book id=\"b1\"&gt;&lt;title&gt;T&lt;/title&gt;&lt;price currency=\"GBP\"&gt;1&lt;/price&gt;&lt;/book&gt;&lt;/catalog&gt;</code></pre>\n<p><code>xmllint --noout</code> printed nothing: it is well formed. The rules for a catalog live in a DTD:</p>\n<pre><code class=\"language-text\">&lt;!ELEMENT book (title, author+, price, tags?)&gt;\n&lt;!ATTLIST price currency (USD|EUR) #REQUIRED&gt;</code></pre>\n<p>and checking against it:</p>\n<pre><code class=\"language-text\">$ xmllint --noout --dtdvalid catalog.dtd book.xml\nelement book: validity error : Element book content does not follow the DTD,\n  expecting (title , author+ , price , tags?), got (title price)\nelement price: validity error : Value \"GBP\" for attribute currency of price is not among the enumerated set\nDocument book.xml does not validate against catalog.dtd</code></pre>\n<p>Two errors: the missing author, and the currency. The good catalog printed <code>valid against DTD</code>. The DTD is the older mechanism and has limits: it knows element order and counts and a few attribute types, but nothing about the content of <code>price</code> beyond \"text\".</p>\n<p>An XML Schema (XSD) is the more expressive tool. It can say that <code>price</code> is a decimal and that the currency is one of two values:</p>\n<pre><code class=\"language-text\">$ xmllint --noout --schema catalog.xsd bad.xml\nElement 'price', attribute 'currency': [facet 'enumeration'] The value 'GBP' is not an element of the set {'USD', 'EUR'}.\nbad.xml fails to validate</code></pre>\n<p>The message is more specific, with the failed rule named (<code>facet 'enumeration'</code>), and an XSD can check types: a price of <code>twelve</code> fails <code>xs:decimal</code>, an <code>id</code> that repeats fails <code>xs:ID</code> uniqueness. Schemas are also longer to write than DTDs. For anything beyond a toy, or when a partner defines the format, the schema is usually supplied and you validate against it.</p>\n<h2>What each check protects you from</h2>\n<p><strong>Well-formedness protects the parser.</strong> It guarantees that a tree exists. If you are only reading a file to extract a few values, it is often the only check you want.</p>\n<p><strong>Validity protects the consumer.</strong> It guarantees the structure that the code downstream assumes: the elements it reads are there, in the right number, with values it can interpret. It is a contract, and the right place to enforce a contract is at the border: when you receive a file from a partner, and when you send one.</p>\n<p>Neither of them protects against wrong content that the rules allow. A valid price of <code>0.01</code> for a book that costs 12.50 passes everything.</p>\n<h2>A third kind of check: your own code</h2>\n<p>Neither level knows your business rules. A valid catalog can list a book at a price of zero, an author that is the empty string, or two books with the same title. Those checks belong in code that runs after the parse, or in Schematron-style rules, which XML has and which few teams use. For most data feeds the pragmatic split is: the parser guarantees well-formedness, a schema (when one exists) guarantees structure, and a short function guarantees that the values make sense. Write all three as tests against a folder of sample files, valid and broken, so the day a partner changes the feed you learn which level failed (<a href=\"/library/cheat-sheet-xml-syntax-rules\">a cheat sheet of XML syntax rules</a> lists the rules the first level enforces, and <a href=\"/library/xml-dtd\">what a DTD is and why you rarely need one now</a> covers the second).</p>\n<h2>Running the checks</h2>\n<p>In the command line, <code>xmllint</code> does both:</p>\n<pre><code class=\"language-bash\">xmllint --noout file.xml                          # well-formed only\nxmllint --noout --dtdvalid file.dtd file.xml      # against an external DTD\nxmllint --noout --schema file.xsd file.xml        # against an XSD</code></pre>\n<p>Notice the first line: a document that declares a <code>DOCTYPE</code> with an external DTD was not validated by plain <code>xmllint --noout</code>, which only checks syntax (and my deliberately invalid example passed). Validation has to be requested. In code, <code>lxml</code> provides <code>etree.DTD</code> and <code>etree.XMLSchema</code> objects whose <code>validate()</code> returns a boolean and whose <code>error_log</code> explains failures (<a href=\"/library/parsing-writing-xml-python\">parsing and writing XML in Python</a> covers the library). You can also look at a document's structure in the <a href=\"/tools/xml-formatter\">XML formatter</a> before you decide what rules to write.</p>\n<p>The specifications are short enough to skim: the XML 1.0 recommendation defines well-formedness and validity in its <a href=\"https://www.w3.org/TR/xml/#sec-well-formed\" target=\"_blank\" rel=\"noopener noreferrer\">second section</a>, and <a href=\"https://www.w3.org/TR/xmlschema-0/\" target=\"_blank\" rel=\"noopener noreferrer\">XML Schema Part 0: Primer</a> is the gentlest introduction to XSD.</p>\n<h2>When well formed is as far as you need to go</h2>\n<p><strong>You are reading a file you trust.</strong> A config file in your own repository does not need a schema. The parse error is enough.</p>\n<p><strong>The format is loose on purpose.</strong> HTML-like documents, user-written notes and extensible vocabularies are checked by code that picks out what it understands and ignores the rest.</p>\n<p><strong>The consumer is tolerant.</strong> If the code reads <code>price</code> and ignores everything it does not know, a schema adds friction without removing a failure.</p>\n<p><strong>You validate by other means.</strong> A test suite that parses real samples and asserts on the extracted values is a form of validation, and is often more useful than a schema nobody maintains.</p>","date_published":"2026-11-10T15:55:00+00:00","tags":["XML"]},{"id":"https://datahacks.dev/library/excel-xls-xlsx-xlsb","url":"https://datahacks.dev/library/excel-xls-xlsx-xlsb","title":"XLS Versus XLSX Versus XLSB and Which to Hand Over","summary":"Compare the XLS, XLSX and XLSB Excel formats on limits, size, macros and library support, detect which one a file really is, and pick one to hand over.","content_html":"<p>Three file extensions cover almost every Excel file you will be given, and they are not interchangeable. A system that exports <code>.xls</code>, a colleague who saves <code>.xlsb</code> to make a heavy file smaller, and a script that expects <code>.xlsx</code> will each cause a different error, and the error messages rarely mention the format. This post compares the three on the properties that matter for moving data, shows how to tell what a file really is regardless of its name, and ends with a recommendation for what to send. It is part of the Excel cluster that starts with <a href=\"/library/inside-excel-file\">what is inside an Excel file</a>.</p>\n<h2>The three formats</h2>\n<p><strong>XLS</strong> is the Excel 97 to 2003 format, a binary container (OLE2, also called Compound File Binary) holding a stream of records. It has 65,536 rows and 256 columns per sheet, and its limits on styles and colors are far lower than modern Excel. It cannot be read by unzipping. Many old systems, report generators and instruments still write it because the library they use was written in 1999.</p>\n<p><strong>XLSX</strong> is the Office Open XML format introduced with Excel 2007: a zip of XML parts, 1,048,576 rows and 16,384 columns, no macros. It is an open standard (ECMA-376 and ISO/IEC 29500), and you can read it with an archive tool and a text editor. It is the format almost every library targets first.</p>\n<p><strong>XLSB</strong> is the Excel binary workbook: also a zip package, but the parts are binary records and not XML. It stores the same data as XLSX with the same limits, usually in a smaller file that Excel loads faster, since parsing binary records is cheaper than parsing XML. It can hold macros. Its drawback is support: Excel and a few libraries read it, and many tools do not.</p>\n<p>There is a fourth member of the family worth naming. <strong>XLSM</strong> is the XLSX layout with macros enabled. It is XML, a zip, and as readable as XLSX, but a <code>vbaProject.bin</code> part carries the code, and many mail filters block it.</p>\n<table><thead><tr><th>Property</th><th>XLS</th><th>XLSX</th><th>XLSB</th></tr></thead><tbody><tr><td>Container</td><td>OLE2 binary</td><td>Zip of XML</td><td>Zip of binary parts</td></tr><tr><td>Rows by columns</td><td>65,536 by 256</td><td>1,048,576 by 16,384</td><td>1,048,576 by 16,384</td></tr><tr><td>Macros</td><td>Yes</td><td>No (use XLSM)</td><td>Yes</td></tr><tr><td>Typical size</td><td>Larger than XLSX for the same data</td><td>Baseline</td><td>Often the smallest</td></tr><tr><td>Readable without Excel</td><td>Hard</td><td>Easy</td><td>Hard</td></tr><tr><td>Library support</td><td>xlrd, calamine</td><td>Nearly everything</td><td>pyxlsb, calamine</td></tr></tbody></table>\n<h2>How the libraries treat each one</h2>\n<p>Support differs more than the table suggests, and this is where handovers fail. In Python:</p>\n<ul><li><p><strong>openpyxl</strong> reads and writes XLSX and XLSM. It does not read XLS or XLSB, and raises an error for them.</p></li><li><p><strong>xlrd</strong> reads XLS. Version 2.0 and later dropped XLSX support, so it is only for the old format.</p></li><li><p><strong>pyxlsb</strong> reads XLSB, with fewer features than openpyxl has for XLSX.</p></li><li><p><strong>python-calamine</strong> is a Rust-based reader that opens XLS, XLSX, XLSB and ODS through one interface. pandas can use it as <code>engine=\"calamine\"</code> in recent versions (<a href=\"/library/excel-choosing-between-openpyxl-xlsxwriter\">choosing between openpyxl, xlsxwriter and calamine</a>).</p></li><li><p><strong>pandas</strong> picks an engine from the extension when it can, and fails with an instruction to install one when it cannot (<a href=\"/library/excel-fixing-missing-optional-dependency-openpyxl\">fixing the missing optional dependency openpyxl error</a>).</p></li></ul>\n<p>DuckDB's Excel extension reads XLSX. The database import wizards of SQL Server and MySQL Workbench have their own rules, and some need a driver for the format: SQL Server's tooling relies on the Access Database Engine (ACE) provider for XLSX and XLSB.</p>\n<p>In practice, if your pipeline is written with openpyxl and someone sends an <code>.xlsb</code>, the pipeline stops. Converting the file once in Excel (Save As, XLSX) is the fastest fix, and the permanent fix is to agree on a handover format.</p>\n<h2>Do not trust the extension</h2>\n<p>Files get misnamed constantly. A web application \"exports to Excel\" by returning an HTML table with an <code>.xls</code> extension, because Excel opens it. Another writes a tab-separated text file and calls it <code>.xls</code>. A script renames a CSV. Excel opens all of these, with a warning that the file format and extension do not match, and every library fails differently. Check the first bytes of the file:</p>\n<pre><code class=\"language-python\">import sys, zipfile\n\nSIGNATURES = {\n    b\"\\xd0\\xcf\\x11\\xe0\\xa1\\xb1\\x1a\\xe1\": \"OLE2 binary (real XLS, or an old DOC or PPT)\",\n    b\"PK\\x03\\x04\": \"zip package (XLSX, XLSM, XLSB, ODS or another zip)\",\n}\n\npath = sys.argv[1]\nwith open(path, \"rb\") as f:\n    head = f.read(8)\n\nkind = next((v for k, v in SIGNATURES.items() if head.startswith(k)), None)\nif kind is None:\n    text = head.lstrip(b\"\\xef\\xbb\\xbf \\r\\n\\t\")[:1]\n    kind = \"HTML or XML text\" if text == b\"&lt;\" else \"plain text (CSV or TSV?)\"\n\nprint(path, \"-&gt;\", kind)\n\nif head.startswith(b\"PK\"):\n    names = zipfile.ZipFile(path).namelist()\n    if \"xl/workbook.bin\" in names:\n        print(\"   workbook.bin present: XLSB\")\n    elif \"xl/workbook.xml\" in names:\n        macros = \"xl/vbaProject.bin\" in names\n        print(\"   workbook.xml present: XLSX\" + (\" with macros (XLSM)\" if macros else \"\"))</code></pre>\n<p>The eight-byte OLE2 signature <code>D0 CF 11 E0 A1 B1 1A E1</code> marks a binary XLS. Every zip begins with <code>PK</code>. Inside a zip, <code>xl/workbook.xml</code> marks the XML flavor and <code>xl/workbook.bin</code> marks XLSB. A file that starts with <code>&lt;</code> is HTML or XML, and one that starts with something else printable is probably delimited text. For a web \"export\" renamed to <code>.xls</code>, pandas can read it with <code>read_html</code>, not <code>read_excel</code>, and the quicker fix is to ask the source for a CSV.</p>\n<h2>Which to hand over</h2>\n<p>The choice depends on who reads the file.</p>\n<p><strong>A person with Excel.</strong> Send XLSX. Every version since 2007 opens it, Google Sheets and LibreOffice open it, and macros are not an issue. Choose XLSB only for an internal file that is slow to open, where everyone who touches it uses desktop Excel, and keep an XLSX copy for anyone else.</p>\n<p><strong>A program written in any language.</strong> Send CSV if the data is one table, and see <a href=\"/library/excel-csv-sharing-data-other\">Excel versus CSV for sharing data with other systems</a>. If styles, several sheets or formulas matter, send XLSX with the data on a single sheet, a header in row 1 and identifiers stored as text.</p>\n<p><strong>An archive.</strong> XLSX, with the data also exported as CSV. The XML layout is documented and readable in the long term, which cannot be said for the binary formats.</p>\n<p><strong>A legacy system that demands XLS.</strong> Send XLS, keep it under 65,536 rows, and make sure no more than 256 columns exist. Write it with a library, not by renaming a CSV, and test the file in the receiving system. Treat it as a bridge that you plan to retire.</p>\n<p>Never hand over XLSM unless the receiver asked for the macros. A file with macros is blocked by many filters and treated with suspicion by good habit.</p>\n<h2>Converting between them</h2>\n<p>Excel's Save As converts one to another and is the most faithful option for a single file. For many files, LibreOffice can run without a window:</p>\n<pre><code class=\"language-bash\">soffice --headless --convert-to xlsx --outdir converted old/*.xls</code></pre>\n<p>The command reads every <code>.xls</code> file in <code>old</code> and writes a <code>.xlsx</code> file for each into <code>converted</code>. Conversion is not lossless. Formatting that has no equivalent is approximated, charts can change, and formulas are recalculated by the converting program, which may differ from Excel in edge cases. Compare totals before and after, as in <a href=\"/library/validating-row-counts-after-excel\">validating row counts after an Excel import</a>. With python-calamine you can skip the conversion and read the old file directly, which avoids the question for data-only use.</p>\n<p>Be careful about the direction. Converting XLSX to XLS to satisfy an old system silently drops every row after 65,536 in some tools, and fails in others. Check the row count first.</p>\n<h2>Size and speed in practice</h2>\n<p>XLSB is often described as 50 to 70 percent smaller than XLSX and several times faster to open. Claims like these come from vendor guidance and depend heavily on the content: a workbook that is mostly text compresses well in either, and one with large formula chains gains the most from the binary layout. I have not measured them here. If you are considering XLSB for speed, convert a representative file and time the open in Excel and the load in your pipeline. XLSX already compresses with zip, so the gap is smaller than the gap between XLS and XLSX was.</p>\n<p>The reverse matters more for data engineering: XLSX parsing is slow because XML parsing is slow, and a million-row file takes noticeable time in any library. A fast reader helps (<a href=\"/library/reading-excel-files-faster-python\">reading Excel files faster with python calamine</a>), and so does reading in a mode that streams instead of loading everything (<a href=\"/library/reading-large-excel-files-read\">reading large Excel files in read-only mode</a>).</p>\n<h2>When none of the three Excel formats is the right handover</h2>\n<p><strong>The data is more than a million rows.</strong> All three formats stop at 1,048,576 rows. Use CSV, a database or a columnar file such as Parquet (<a href=\"/library/excel-row-column-limits\">Excel row and column limits</a>).</p>\n<p><strong>The receiver is a machine and the data has types.</strong> A spreadsheet carries only numbers, strings, booleans and dates-as-numbers. Typed formats such as Parquet or Avro, or JSON with a schema, say more.</p>\n<p><strong>You need version control.</strong> Binary and zipped formats diff poorly. Keep the source as CSV in the repository and generate workbooks from it.</p>\n<p><strong>The file is a template with logic.</strong> Formulas and macros that the receiver must keep working are a reason for XLSX or XLSM, but also a reason to ask whether the logic belongs in code.</p>\n<p>The structure of the package formats is described in <a href=\"https://ecma-international.org/publications-and-standards/standards/ecma-376/\" target=\"_blank\" rel=\"noopener noreferrer\">ECMA-376</a>, and the binary workbook format in Microsoft's <a href=\"https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xlsb/\" target=\"_blank\" rel=\"noopener noreferrer\">MS-XLSB specification</a>. For a quick look at a small table as JSON, use the <a href=\"/tools/csv-json\">CSV to JSON converter</a>.</p>","date_published":"2026-11-10T00:34:00+00:00","tags":["Excel"]},{"id":"https://datahacks.dev/library/reading-yaml-front-matter-markdown","url":"https://datahacks.dev/library/reading-yaml-front-matter-markdown","title":"Reading YAML Front Matter From Markdown Files in Python","summary":"Split a Markdown file into YAML metadata and body with one regex, then handle dates, CRLF, a BOM, a missing closing line and body text with dashes.","content_html":"<p>Static site generators, documentation tools and note-taking apps keep a bit of YAML at the top of each Markdown file: a title, a date, tags, a draft flag. This is called front matter. If you write a script that builds an index, finds drafts or renames files from their metadata, you need to split the file and parse the top block. The regular expression takes one line and the edge cases take the rest of this post, with each tested.</p>\n<h2>The format</h2>\n<pre><code class=\"language-markdown\">---\ntitle: Hello\ndate: 2026-10-27\ntags: [a, b]\ndraft: no\n---\n# Body\nSome text</code></pre>\n<p>The first line must be exactly three dashes. The block ends at the next line of three dashes (some tools also accept three dots). Everything after that line is the body. Hugo, Jekyll, Eleventy and many others use this convention, and the loader in your script needs to follow the same rules.</p>\n<h2>The splitter</h2>\n<pre data-filename=\"fm.py\"><code class=\"language-python\">import re\nimport yaml\n\nFM = re.compile(r\"\\A﻿?---[ \\t]*\\r?\\n(.*?)\\r?\\n(?:---|\\.\\.\\.)[ \\t]*(?:\\r?\\n|\\Z)(.*)\\Z\", re.S)\n\ndef split(text):\n    m = FM.match(text)\n    if not m:\n        return None, text\n    return yaml.safe_load(m.group(1)), m.group(2)</code></pre>\n<p>The pattern is anchored at the start (<code>\\A</code>), allows an optional byte order mark, requires <code>---</code> on the first line, captures lazily (<code>.*?</code>) up to the first closing line and captures the rest as the body. <code>re.S</code> lets the dot match newlines. The lazy match is what protects the body: it stops at the <em>first</em> closing marker. Results on a normal file:</p>\n<pre><code class=\"language-text\">normal   meta={'title': 'Hello', 'date': datetime.date(2026, 10, 27), 'tags': ['a', 'b'], 'draft': False}  body='# Body\\nSome text\\n-...'\n{'title': 'str', 'date': 'date', 'tags': 'list', 'draft': 'bool'}</code></pre>\n<p>The body starts at <code># Body</code> and keeps the later <code>---</code> line, which in Markdown is a horizontal rule, untouched. The metadata types show two YAML conversions to be aware of. <code>date: 2026-10-27</code> is a <code>datetime.date</code>, because YAML resolves the timestamp. And <code>draft: no</code> is the boolean <code>False</code>, because PyYAML implements YAML 1.1 (<a href=\"/library/norway-problem-yaml-boolean-surprises\">the Norway problem and YAML boolean surprises</a>). That is convenient for a draft flag and a trap for any other field that happens to hold <code>no</code>, <code>on</code> or <code>yes</code> as text.</p>\n<h2>Dates and JSON</h2>\n<p>If you pass the metadata to <code>json.dumps</code> to build an index, the date breaks it:</p>\n<pre><code class=\"language-text\">json.dumps: Object of type date is not JSON serializable</code></pre>\n<p>Three fixes. Convert in the dump: <code>json.dumps(meta, default=str)</code> writes <code>\"2026-10-27\"</code>. Or quote the date in the front matter so it is a string from the start. Or convert deliberately after loading: <code>meta[\"date\"] = meta[\"date\"].isoformat()</code> when it is a date. The last is the most explicit and lets you handle both date and datetime. The first is the least code and also converts other objects you did not expect to <code>str</code>, so check what you get.</p>\n<p>A related trap: a date written with a time zone offset, a time of day without a date, or a malformed date such as <code>2026-13-45</code> is a string or an error depending on the form. If your site depends on dates, validate them after loading (<a href=\"/library/validating-yaml-configuration-pydantic\">validating YAML configuration with pydantic</a> works on front matter dictionaries too).</p>\n<h2>Edge cases, tested</h2>\n<pre><code class=\"language-text\">crlf                 meta={'title': 'Win'}                       body='Body\\r\\n'\nbom                  meta={'title': 'Bom'}                       body='Body\\n'\nno front matter      meta=None                                   body='# Just a heading\\nt...'\nempty front matter   meta=None                                   body='---\\n---\\nBody\\n'\nnot closed           meta=None                                   body='---\\ntitle: Open\\nBo...'\nbad yaml             YAMLError mapping values are not allowed here (line 1)</code></pre>\n<p><strong>Windows line endings</strong> work because the pattern accepts <code>\\r?\\n</code>. The body keeps its <code>\\r\\n</code> endings, so normalize them if your output tool cares.</p>\n<p><strong>A byte order mark</strong> at the start (some Windows editors add one) is allowed by <code>﻿?</code>. Without it the first line is not <code>---</code> and the split returns no metadata, a bug that appears only for files from one editor. Open files with <code>encoding=\"utf-8-sig\"</code> to strip the mark at the source.</p>\n<p><strong>No front matter</strong> returns <code>(None, text)</code>. Callers must handle <code>None</code>, for instance with <code>meta or {}</code>.</p>\n<p><strong>An empty block</strong> (two dash lines in a row) did not match the pattern in this version, because the pattern requires a line break before the closing marker, and the result was no metadata with the body unsplit. If empty blocks occur in your files, adjust the pattern to make the content group optional, and decide whether an empty block means an empty dictionary.</p>\n<p><strong>An unclosed block</strong> returns no metadata, and the whole file, including the stray <code>---</code>, is the body. A script that silently treats such a file as having no front matter will hide a typo. Consider raising when the first line is <code>---</code> but no closing line exists.</p>\n<p><strong>Invalid YAML in the block</strong> raises a normal <code>YAMLError</code>. Its line number counts from the start of the <em>block</em>, not the file, so line 1 of the error is line 2 of the file. Add the offset (one line for the opening marker, or two with a BOM) before showing it to a person.</p>\n<h2>Tools that already do this</h2>\n<p>You do not have to write the splitter. The <code>python-frontmatter</code> package parses front matter in YAML, TOML or JSON and returns a post object with metadata and content, and it handles several of the cases above. <code>yq</code> can read front matter on the command line: <code>yq --front-matter=extract '.title' post.md</code> printed the metadata of a Markdown file in a shell script. Static site generators expose it to templates already. Write your own when you need to control the exact rules, as for a linter that enforces required fields.</p>\n<h2>A small linter</h2>\n<p>A checker for a content repository is a useful application: required keys present, dates valid, tags a list, no drafts in the publish directory. Walk the files with <code>glob</code>, split each, validate the dictionary and print one line per problem with the file name. Run it in CI. Because front matter is user-written YAML, everything in this cluster applies: quote titles that contain a colon, quote anything that could be misread, watch for duplicate keys and keep the block short (<a href=\"/library/yaml-strings-quoting-quotes\">YAML strings, quoting and when you need quotes</a>). A title such as <code>Fix: the parser</code> is the commonest failure, and it produces \"mapping values are not allowed here\" on line 1 of the block.</p>\n<h2>Related reading</h2>\n<p>The cluster hub is <a href=\"/library/reading-writing-yaml-python-safely\">reading and writing YAML in Python safely</a>. The loading call is explained in <a href=\"/library/loading-yaml-python-pyyaml-safe\">loading YAML in Python with PyYAML safe_load</a>. The convention is described in the <a href=\"https://jekyllrb.com/docs/front-matter/\" target=\"_blank\" rel=\"noopener noreferrer\">Jekyll front matter documentation</a> and the library at <a href=\"https://python-frontmatter.readthedocs.io/\" target=\"_blank\" rel=\"noopener noreferrer\">python-frontmatter</a>. Check a block's types in the <a href=\"/tools/yaml-json\">YAML to JSON tool</a>.</p>\n<h2>When a regex splitter is not enough for front matter</h2>\n<p><strong>The files use several metadata formats.</strong> TOML between plus signs and JSON between braces are also used. A library handles all three.</p>\n<p><strong>You rewrite the front matter.</strong> Editing and writing it back needs a round-trip YAML library to keep comments and order (<a href=\"/library/preserving-comments-order-ruamel-yaml\">preserving comments and order with ruamel yaml</a>).</p>\n<p><strong>The body contains lines that look like the closing marker.</strong> In a fenced code block, a line of three dashes could be mistaken only if it comes before the real closing line, which it cannot. The lazy match is safe for well-formed files.</p>\n<p><strong>You need exact positions.</strong> For error reporting inside editors, track the offset of the block, not just its content.</p>","date_published":"2026-11-09T15:11:00+00:00","tags":["YAML"]},{"id":"https://datahacks.dev/library/installing-mysql-8-4-macos","url":"https://datahacks.dev/library/installing-mysql-8-4-macos","title":"Installing MySQL 8.4 on macOS Linux and Windows Step by Step","summary":"Install MySQL 8.4 LTS on macOS, Ubuntu and Windows: package sources, first start, the root account, secure setup and checks that the server works.","content_html":"<p>Installing MySQL looks routine and has a few choices that matter later: which release line, which package source, where the data lives and how the root account starts out. This article installs MySQL 8.4 LTS on the three common desktop and server platforms, walks through the first start and secures the result. It sticks to supported installers from the project and Homebrew, and it ends with checks that the server is what you expect before an application connects.</p>\n<h2>Pick the release</h2>\n<p>MySQL has long term support releases and innovation releases. Version 8.4 is the LTS line, supported for years with bug and security fixes and no feature changes, and it is the right default for a database that will run for a long time. Innovation releases such as 9.x ship new features quickly and are supported only until the next one. Version 8.0 is at or near end of life according to Oracle's lifetime policy, so do not start new work on it. The reasoning is in <a href=\"/library/mysql-version-choose-new\">which MySQL version to choose for a new project</a>. MariaDB is a separate fork with its own packages and differences, not covered here.</p>\n<h2>macOS with Homebrew</h2>\n<pre><code class=\"language-bash\">brew install mysql@8.4\nbrew services start mysql@8.4</code></pre>\n<p>Homebrew installs a versioned formula and puts binaries under its prefix without linking them to your path, so add them: <code>echo 'export PATH=\"/opt/homebrew/opt/mysql@8.4/bin:$PATH\"' &gt;&gt; ~/.zshrc</code> on Apple silicon (the prefix is <code>/usr/local</code> on Intel Macs). The data directory is <code>/opt/homebrew/var/mysql</code> and the configuration file <code>/opt/homebrew/etc/my.cnf</code>. The root account starts with no password, reachable only from localhost. The installer from the MySQL download page is an alternative that registers a system service and prompts for a root password.</p>\n<h2>Ubuntu and Debian</h2>\n<p>The distribution repositories often carry an older version, so add the MySQL APT repository. Download the <code>mysql-apt-config</code> package from the MySQL downloads page, install it with <code>sudo dpkg -i</code>, choose the 8.4 LTS server in its menu, and then:</p>\n<pre><code class=\"language-bash\">sudo apt update\nsudo apt install mysql-server\nsudo systemctl status mysql</code></pre>\n<p>The installer asks for a root password, or sets up authentication through the socket so that <code>sudo mysql</code> works without one. Data lives in <code>/var/lib/mysql</code>, configuration in <code>/etc/mysql/</code>, and logs in <code>/var/log/mysql/</code> or the journal. Red Hat family systems use the MySQL Yum repository with <code>dnf install mysql-community-server</code>; the first start writes a temporary root password to the error log (<code>sudo grep 'temporary password' /var/log/mysqld.log</code>) that you must change at first login.</p>\n<h2>Windows</h2>\n<p>Download the MySQL Installer (MSI) from the MySQL website, choose the Server only or Developer setup, pick 8.4, and set the root password and the service name when the wizard asks. The server runs as a Windows service, <code>MySQL84</code> by default, and its files sit under <code>C:\\\\ProgramData\\\\MySQL\\\\MySQL Server 8.4\\\\</code> with <code>my.ini</code> as the configuration file. Use the \"MySQL 8.4 Command Line Client\" shortcut or add the <code>bin</code> directory to the path. Keep the data directory off folders that a backup or antivirus tool scans aggressively, since it hurts performance.</p>\n<h2>First start and the root account</h2>\n<p>On first start the server initializes the data directory. Depending on the package, root has an empty password, a generated temporary one or socket authentication. Replace it at once:</p>\n<pre><code class=\"language-sql\">ALTER USER 'root'@'localhost' IDENTIFIED BY 'a-long-generated-password';</code></pre>\n<p>Version 8.4 defaults to the <code>caching_sha2_password</code> plugin, and the older <code>mysql_native_password</code> is disabled by default, which matters for old clients and is covered in <a href=\"/library/mysql-fixing-authentication-plugin-caching-sha2\">fixing authentication plugin caching_sha2_password errors</a>. Then run the hardening script:</p>\n<pre><code class=\"language-bash\">sudo mysql_secure_installation</code></pre>\n<p>It sets the password policy, removes anonymous accounts and the <code>test</code> database, and disallows remote root login. The details of what it changes are in <a href=\"/library/mysql-removing-anonymous-users-test-databases\">removing anonymous users and test databases</a>.</p>\n<h2>Check what you installed</h2>\n<p>Connect and ask the server:</p>\n<pre><code class=\"language-sql\">SELECT VERSION();\nSHOW VARIABLES LIKE 'character_set_server';\nSHOW VARIABLES LIKE 'datadir';\nSHOW VARIABLES LIKE 'port';</code></pre>\n<p>You should see a version starting with <code>8.4</code>, <code>utf8mb4</code> as the server character set (the right default; see <a href=\"/library/mysql-utf8mb4-only-charset-should\">why utf8mb4 is the only charset you should use</a>), the data directory you expect and port 3306. Check that the server listens only where intended: <code>SHOW VARIABLES LIKE 'bind_address'</code> is <code>127.0.0.1</code> or <code>*</code> depending on the package, and opening it to the network without a firewall and TLS is a decision to make later with care. Look at the error log once to confirm a clean start with no warnings about deprecated settings.</p>\n<h2>Keeping the install updatable</h2>\n<p>Write down how the server was installed and how it will be updated. Package-managed installs receive minor updates through the package manager, and a monthly check of the release notes for security fixes is a good habit. Homebrew upgrades replace the formula in place, and a major version change needs a deliberate step. Pin the version in configuration management so that a new machine matches the old one. Keep the configuration file in version control, and note any non-default setting with the reason, since the file is the part of the install that people forget to document.</p>\n<h2>Common first problems</h2>\n<p>If the service does not start, read the error log, whose location is <code>SHOW VARIABLES LIKE 'log_error'</code> or the journal. Typical causes are a port already in use by another MySQL or MariaDB, permissions on the data directory, or a leftover data directory from another version that the new server refuses to open. <code>ERROR 1045 Access denied</code> for root is covered in <a href=\"/library/mysql-fixing-access-denied-user-root\">fixing access denied for user root at localhost</a>, and a missing socket in <a href=\"/library/fixing-not-connect-local-mysql\">fixing can not connect to local MySQL server through socket</a>. On macOS, an older Homebrew MySQL or the Oracle installer may already occupy the port; stop it first.</p>\n<h2>When Docker is the easier install</h2>\n<p>For development, a container keeps the server, its version and its files apart from your system and removes the platform differences above. One command starts a throwaway or persistent server, as shown in <a href=\"/library/running-mysql-docker-data-volume\">running MySQL in Docker with a data volume</a>. Use native packages for a server that stays up, a machine where Docker is not available or when you want the service manager integration. For production on a managed cloud, none of this applies, since the provider installs and patches the server.</p>\n<h2>What to do next</h2>\n<p>Install 8.4 on the platform you use, change the root password, run the secure script and create an application user. Save the commands you used in your notes so the next machine is a copy. The manual documents every platform in <a href=\"https://dev.mysql.com/doc/refman/8.4/en/installing.html\" target=\"_blank\" rel=\"noopener noreferrer\">installing MySQL</a>, and the Homebrew formula is described at <a href=\"https://formulae.brew.sh/formula/mysql@8.4\" target=\"_blank\" rel=\"noopener noreferrer\">formulae.brew.sh</a>. The hub is <a href=\"/library/getting-mysql-server-running\">getting a MySQL server running and usable</a>.</p>","date_published":"2026-11-07T00:35:00+00:00","tags":["MySQL"]},{"id":"https://datahacks.dev/library/json-data-types-missing","url":"https://datahacks.dev/library/json-data-types-missing","title":"JSON Data Types and What Is Missing From Them Entirely","summary":"JSON has six value types and no date, binary, integer, decimal, set or undefined. See what Python and JavaScript do to each missing type when they serialize.","content_html":"<p>JSON looks like it has types because JavaScript does. It has six, and they are fewer than the ones you use. A value is an object, an array, a string, a number, <code>true</code> or <code>false</code>, or <code>null</code>. There is no date, no binary blob, no set, no map with non-string keys, no decimal that stays exact, no distinction between an integer and a float, no <code>undefined</code> and no way to write infinity. Whatever your program has beyond those six, it has to squeeze into them when it writes JSON and rebuild when it reads, and the squeeze is where data changes without an error. This article runs the missing types through Python and JavaScript, shows what each does and gives the encodings that keep the data intact. It builds on the grammar described in <a href=\"/library/json-allows-parsers-quietly-accept\">what JSON allows and what parsers quietly accept</a>.</p>\n<h2>The six, and what they hold</h2>\n<p>An <strong>object</strong> maps string names to values. An <strong>array</strong> is an ordered list. A <strong>string</strong> is Unicode text. A <strong>number</strong> is a decimal literal with no stated range. <code>true</code> and <code>false</code> are the booleans, and <code>null</code> is the one value for nothing. That is the full list.</p>\n<p>Two properties follow that matter later. Object keys are always strings. And a number is just digits: JSON does not say that <code>1</code> is an integer and <code>1.0</code> a float, or that a number fits in 64 bits. Reading those distinctions is up to the parser, and different parsers read them differently (<a href=\"/library/json-handles-big-numbers\">how JSON handles big numbers</a>).</p>\n<h2>Python: refuse or convert</h2>\n<p>Python's <code>json.dumps</code> knows the six types plus a few obvious cousins: <code>dict</code>, <code>list</code> and <code>tuple</code> (written as an array), <code>str</code>, <code>int</code>, <code>float</code>, <code>bool</code> and <code>None</code>. For anything else it raises:</p>\n<pre><code class=\"language-text\">datetime   TypeError: Object of type datetime is not JSON serializable\nset        TypeError: Object of type set is not JSON serializable\nbytes      TypeError: Object of type bytes is not JSON serializable\nDecimal    TypeError: Object of type Decimal is not JSON serializable\ntuple      [1, 2]\nint key    {\"1\": \"a\"}\ntuple key  TypeError: keys must be str, int, float, bool or None, not tuple\nnan        NaN</code></pre>\n<p>The errors are the safe behavior: the library will not guess how to write a date, so you must say. The silent conversions are the dangerous ones. A tuple becomes an array, and when it is read back it is a list, so <code>(1, 2)</code> does not survive a round trip. An integer key becomes a string: <code>{1: \"a\"}</code> is written <code>{\"1\": \"a\"}</code>, and reading it back gives <code>{'1': 'a'}</code>, so the key's type changed, which is a classic cause of a lookup that works in memory and fails after a cache. And <code>float(\"nan\")</code> is written as the bare word <code>NaN</code>, which is not valid JSON; Python allows it by default, and most other parsers reject it. Turn that off with <code>allow_nan=False</code>, which raises <code>ValueError: Out of range float values are not JSON compliant: nan</code> (<a href=\"/library/nan-infinity-valid-json\">is NaN or Infinity valid in JSON</a>).</p>\n<p>To serialize a type that Python does not know, pass a <code>default</code> function that converts it, for example turning a <code>datetime</code> into an ISO 8601 string and a <code>Decimal</code> into a string, and raising <code>TypeError</code> for anything unknown so that the safe behavior remains.</p>\n<h2>JavaScript: quietly change</h2>\n<p><code>JSON.stringify</code> is more permissive, and its choices are worth knowing because they lose information without a message. The same sort of values:</p>\n<pre><code class=\"language-text\">undefined    {\"b\":1}                      (the key with undefined is dropped)\nfunction     {\"b\":1}                      (so is a method)\narray undef  [null,null]                  (in an array, undefined and functions become null)\nDate         {\"d\":\"2026-10-02T08:15:00.000Z\"}\nNaN          {\"n\":null,\"i\":null}          (NaN and Infinity become null)\nMap          {}\nSet          {}\nBigInt       TypeError: Do not know how to serialize a BigInt\nSymbol key   {\"y\":2}</code></pre>\n<p>A property whose value is <code>undefined</code> disappears: <code>{ a: undefined, b: 1 }</code> serializes as <code>{\"b\":1}</code>, so \"the field is missing\" and \"the field was set to undefined\" are the same on the wire. The same value in an array becomes <code>null</code>, so an array's length is preserved and its content is not. <code>NaN</code> and <code>Infinity</code> become <code>null</code>, which is valid JSON and a silent change of meaning: a missing measurement and a division by zero look identical. A <code>Map</code> and a <code>Set</code> serialize as <code>{}</code>, an empty object, with no error, which is the worst of these, because the data is gone and the output looks fine. And a <code>BigInt</code> raises, which is again the safe behavior. A <code>Date</code> has a <code>toJSON</code> method and becomes an ISO 8601 string in UTC with milliseconds: <code>2026-10-02T08:15:00.000Z</code>. When it is read back, it is a string, not a date, and you must convert.</p>\n<h2>Encoding the missing types</h2>\n<p>Each missing type needs an agreed encoding, and the right one depends on how the data is used. The usual choices:</p>\n<table><thead><tr><th>Type</th><th>Common encoding</th><th>Notes</th></tr></thead><tbody><tr><td>Date or timestamp</td><td>ISO 8601 string, UTC or with an offset</td><td>Never a locale string; see <a href=\"/library/json-dates-lack-date\">JSON dates</a></td></tr><tr><td>Binary</td><td>base64 string</td><td>Adds a third in size; say the variant (standard or URL-safe)</td></tr><tr><td>Decimal and money</td><td>string, or integer minor units</td><td>Floats cannot hold 0.1 exactly</td></tr><tr><td>Big integer ID</td><td>string</td><td>Above 2^53 a float loses digits</td></tr><tr><td>Set</td><td>array, sorted if order should not matter</td><td>Document that it is a set</td></tr><tr><td>Map with non-string keys</td><td>array of <code>[key, value]</code> pairs, or an array of objects</td><td>Keys must be strings in an object</td></tr><tr><td>Integer vs float</td><td>the number as written, and a schema that says which</td><td><code>1</code> and <code>1.0</code> may read the same in JavaScript</td></tr><tr><td>Missing value</td><td>omit the field, or <code>null</code></td><td>Choose one; say whether they differ</td></tr><tr><td>Infinity, NaN</td><td><code>null</code>, or a string, or omit</td><td>Not representable as numbers</td></tr><tr><td>Enum</td><td>string</td><td>Stable names, not numbers</td></tr></tbody></table>\n<p>Write the choices in the interface description, ideally with a JSON Schema, and test them in both directions: write, read back, compare the typed values, not the text. A date serialized by one service as <code>2026-10-02T08:15:00Z</code> and by another as <code>2026-10-02T08:15:00.000+00:00</code> is the same instant and different text, so consumers must parse, not compare strings.</p>\n<h2>The null and missing question</h2>\n<p>JSON's <code>null</code> raises a design question that the type list hides. A field can be present with a value, present as <code>null</code>, or absent, and those are three states that many APIs collapse into two. In JavaScript, absent and <code>undefined</code> coincide on the wire. In databases, <code>null</code> means unknown. In PATCH-style updates, an absent field means \"leave as is\" and <code>null</code> means \"clear it\", and a client that drops <code>null</code> fields as an optimization breaks the second. Decide the semantics per API, write them down and test the three cases. In CSV the same question appears as an empty cell, and <a href=\"/library/csv-round-tripping-exports-back-imports\">round tripping exports back into imports</a> shows what happens when the distinction is lost.</p>\n<h2>When you need types JSON cannot express</h2>\n<p>If the data is full of types JSON lacks, dates, decimals, binary, sets, the encodings above work and cost you a schema and discipline. For some jobs a different format is better. Protocol Buffers, Avro and Parquet carry types in the schema; MessagePack and CBOR add binary and a few types while keeping the JSON data model; BSON adds dates, binary and 64-bit integers for MongoDB. If humans do not need to read the bytes, those formats remove the guesswork. If they do, JSON with a schema is a fair middle path.</p>\n<p>Whichever way you go, make the encoding explicit in code, with a small serializer function in one place that every write goes through, and a matching deserializer for reads. The scattered <code>str(value)</code> calls are where a date gets written two ways. The Python behavior is documented in the <a href=\"https://docs.python.org/3/library/json.html\" target=\"_blank\" rel=\"noopener noreferrer\">json module reference</a>, and the JavaScript rules in the <a href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify\" target=\"_blank\" rel=\"noopener noreferrer\">MDN page for JSON.stringify</a>.</p>","date_published":"2026-11-06T15:33:00+00:00","tags":["JSON"]},{"id":"https://datahacks.dev/library/running-postgresql-docker-persistent-volume","url":"https://datahacks.dev/library/running-postgresql-docker-persistent-volume","title":"Running PostgreSQL in Docker With a Persistent Volume","summary":"Run PostgreSQL in Docker with a named volume so data survives restarts, set the password and database on first start, publish the port safely and use Compose.","content_html":"<p>A container is the quickest way to get a clean PostgreSQL on any machine, and it has one trap that catches nearly everyone once: containers are disposable, and a database inside a container's own filesystem is disposable with it. Remove the container and the data goes too. The fix is a volume, and with it the container becomes the right tool: start a server in a second, throw it away, start another against the same data. This article shows the command, the settings that apply only on first start, the Compose version and the mistakes around ports and versions. It is the container route in <a href=\"/library/setting-up-postgresql-development\">setting up PostgreSQL for development without fighting it</a>, and the native route is in <a href=\"/library/installing-postgresql-macos-linux\">installing PostgreSQL on macOS, Linux and Windows</a>. Once it runs, <a href=\"/library/postgresql-creating-first-database-user\">creating your first database user and schema in PostgreSQL</a> is the next step.</p>\n<h2>The command</h2>\n<pre><code class=\"language-bash\">docker volume create pg17data\n\ndocker run --name pg17 -d \\\n  -e POSTGRES_PASSWORD=dev-only-password \\\n  -e POSTGRES_USER=shop \\\n  -e POSTGRES_DB=shop \\\n  -p 127.0.0.1:5432:5432 \\\n  -v pg17data:/var/lib/postgresql/data \\\n  postgres:17</code></pre>\n<p>Each part has a job.</p>\n<p><code>--name pg17</code> gives the container a stable name for later commands (<code>docker stop pg17</code>, <code>docker logs pg17</code>). <code>-d</code> runs it in the background.</p>\n<p><code>-e POSTGRES_PASSWORD=...</code> is required; the image refuses to start without a password for the superuser (or an explicit decision to allow trust authentication, which you should not make). <code>POSTGRES_USER</code> names the superuser, defaulting to <code>postgres</code>; <code>POSTGRES_DB</code> names a database to create, defaulting to the user's name.</p>\n<p><code>-p 127.0.0.1:5432:5432</code> publishes container port 5432 on the host's loopback interface only. Written as <code>-p 5432:5432</code>, Docker publishes on all interfaces, which on a laptop on a shared network exposes the database. If port 5432 is taken on the host by another PostgreSQL, map a different host port: <code>-p 127.0.0.1:5433:5432</code>, and connect to 5433.</p>\n<p><code>-v pg17data:/var/lib/postgresql/data</code> mounts the named volume at the data directory, so the database files live in the volume.</p>\n<p><code>postgres:17</code> pins the major version. A bare <code>postgres</code> means <code>latest</code>, which moves to the next major when it is released; an existing volume created by 17 will not start under 18 (the server says the files are from an incompatible version) and you are left with an upgrade you did not plan.</p>\n<p>Check it:</p>\n<pre><code class=\"language-bash\">docker logs pg17 | tail -5\ndocker exec pg17 pg_isready -U shop -d shop\npsql \"postgresql://shop:dev-only-password@127.0.0.1:5432/shop\" -c \"SELECT version();\"</code></pre>\n<p>The log should end with <code>database system is ready to accept connections</code>.</p>\n<h2>The first-start-only rule</h2>\n<p>The image's entrypoint looks at the data directory when the container starts. If it is empty, the entrypoint runs <code>initdb</code>, creates the superuser and database from the environment variables and runs any initialization scripts. If it already contains a cluster, it skips all of that and starts the server.</p>\n<p>So changing <code>POSTGRES_PASSWORD</code> in a command or a Compose file and restarting does nothing to an existing volume: the old password still applies. That is the single most common Docker PostgreSQL confusion (\"I set the password but it says authentication failed\"). To change a password, change it inside the database (<code>ALTER ROLE shop PASSWORD '...'</code>). To start over with new settings, remove the volume, which deletes the data:</p>\n<pre><code class=\"language-bash\">docker rm -f pg17\ndocker volume rm pg17data</code></pre>\n<p>Do this only when you mean it. <code>docker volume ls</code> shows which volumes exist, and <code>docker volume inspect pg17data</code> shows where the data lives on the host.</p>\n<h2>Initialization scripts</h2>\n<p>Files placed in <code>/docker-entrypoint-initdb.d/</code> run once, on first start, in alphabetical order. They can be <code>.sql</code>, <code>.sql.gz</code> or <code>.sh</code>. This is the right place for schema and seed data.</p>\n<pre><code class=\"language-bash\">docker run --name pg17 -d \\\n  -e POSTGRES_PASSWORD=dev-only-password \\\n  -v pg17data:/var/lib/postgresql/data \\\n  -v \"$PWD/db/init\":/docker-entrypoint-initdb.d:ro \\\n  postgres:17</code></pre>\n<p>Scripts run as the superuser against the <code>POSTGRES_DB</code> database. They run only when the data directory is empty, so editing them later has no effect on an existing volume; apply changes with migrations instead. A failing script aborts the initialization, and the container exits. Check <code>docker logs</code> for the line with the error.</p>\n<h2>Docker Compose</h2>\n<p>Compose records the same settings in a file the whole team shares.</p>\n<pre><code class=\"language-yaml\">services:\n  db:\n    image: postgres:17\n    container_name: shop-db\n    restart: unless-stopped\n    environment:\n      POSTGRES_USER: shop\n      POSTGRES_PASSWORD: dev-only-password\n      POSTGRES_DB: shop\n    ports:\n      - \"127.0.0.1:5432:5432\"\n    volumes:\n      - pgdata:/var/lib/postgresql/data\n      - ./db/init:/docker-entrypoint-initdb.d:ro\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U shop -d shop\"]\n      interval: 5s\n      timeout: 3s\n      retries: 10\n\nvolumes:\n  pgdata:</code></pre>\n<p><code>docker compose up -d</code> starts it, <code>docker compose logs -f db</code> follows the log, <code>docker compose down</code> stops and removes the container but keeps the volume, and <code>docker compose down -v</code> also removes the volumes, which deletes the data. The health check lets other services use <code>depends_on</code> with <code>condition: service_healthy</code> to wait until the database accepts connections. Keep real passwords out of the file: use an <code>.env</code> file that Compose reads (and that is not committed), or Docker secrets.</p>\n<p>Other containers on the same Compose network reach the database at the hostname <code>db</code> (the service name) on port 5432, not <code>localhost</code> and not the published host port. A web service whose connection string says <code>localhost</code> inside its own container is talking to itself.</p>\n<h2>Named volume or bind mount</h2>\n<p>A <strong>named volume</strong> is managed by Docker, with good performance on all platforms and a layout you do not touch. It is the right default. A <strong>bind mount</strong> (<code>-v ./pgdata:/var/lib/postgresql/data</code>) maps a host directory, which makes the files visible and easy to back up, and brings trouble: on Docker Desktop for Mac and Windows the performance can be poor, permissions between host and container must line up (the data directory must be owned by the container's postgres user and mode 0700) and a stray <code>git add</code> can try to commit a database. Use a named volume unless you have a reason.</p>\n<h2>Where the data directory is</h2>\n<p>The mount path above, <code>/var/lib/postgresql/data</code>, was the image's data directory for many versions. Newer major versions of the official image moved the default data directory to a version-specific location under <code>/var/lib/postgresql</code>, and recommend mounting the volume at that parent path. Mounting at the old path with a new image can create an anonymous volume that you do not expect. Read the image's Docker Hub page for the exact guidance for your major version before you choose the mount, and verify with <code>docker exec pg17 psql -U shop -c \"SHOW data_directory;\"</code> that the path reported is the one you mounted.</p>\n<h2>Backups and moving data</h2>\n<p>A volume is not a backup. Take dumps from the running container:</p>\n<pre><code class=\"language-bash\">docker exec pg17 pg_dump -U shop -Fc shop &gt; shop.dump\ncat shop.dump | docker exec -i pg17 pg_restore -U shop -d shop_copy --no-owner</code></pre>\n<p>Create the target database first. The dump format is portable across hosts and across minor versions. To copy a volume's raw files to another machine, stop the container first; copying a running data directory yields an inconsistent copy.</p>\n<h2>Settings and extensions</h2>\n<p>Pass server settings on the command line after the image name: <code>postgres:17 -c log_min_duration_statement=200 -c shared_buffers=256MB</code>. Or mount a config file and point to it with <code>-c config_file=/etc/postgresql/postgresql.conf</code>. Extensions that ship with the server (<code>pg_stat_statements</code>, <code>pgcrypto</code>, <code>pg_trgm</code>) are available in the standard image; others need a custom image built <code>FROM postgres:17</code> with the package added. Extensions are enabled with <code>CREATE EXTENSION</code> in each database that uses them.</p>\n<p>Give the container a reasonable shared memory size if you raise <code>shared_buffers</code> a lot (<code>--shm-size=256m</code>), since the container's default <code>/dev/shm</code> is small.</p>\n<h2>Common problems</h2>\n<p><strong>Authentication failed after changing the password variable.</strong> The volume already existed; see above.</p>\n<p><strong>Container exits immediately.</strong> <code>docker logs</code> shows why: a missing <code>POSTGRES_PASSWORD</code>, an incompatible data directory or a failing init script.</p>\n<p><strong>Port already allocated.</strong> Another process holds 5432. Change the host port.</p>\n<p><strong>Connection refused from the host right after start.</strong> The server is still initializing; wait for the ready message or use the health check.</p>\n<p><strong>Data gone.</strong> The container ran without a volume, or <code>docker compose down -v</code> ran. A dump is the only protection.</p>\n<p><strong>Permission denied on a bind mount.</strong> The directory's owner or mode is wrong for the container user.</p>\n<h2>When Docker is the wrong way to run PostgreSQL</h2>\n<p>Docker is a good fit for development and tests. In production it is a decision with trade-offs: you take on the care of volumes, upgrades, backups and storage performance, which a managed service or a well-run native install handles for you. On a machine with limited resources, or on Docker Desktop with slow file sharing, a native install may feel faster. And when a team shares one long-lived development database, a server with proper backups beats everyone's laptop containers. Use containers for the disposable parts. The <a href=\"https://hub.docker.com/_/postgres\" target=\"_blank\" rel=\"noopener noreferrer\">official image page</a> documents every variable and path, and the <a href=\"https://www.postgresql.org/docs/current/runtime.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL manual on server setup</a> covers the settings inside.</p>","date_published":"2026-11-06T00:23:00+00:00","tags":["PostgreSQL"]},{"id":"https://datahacks.dev/library/converting-csv-parquet","url":"https://datahacks.dev/library/converting-csv-parquet","title":"Converting a CSV to Parquet and Why You Might Want To","summary":"One DuckDB statement turned a 130 MB CSV into a 34 MB Parquet file; typed columns, dictionary encoding and min/max statistics explain the faster queries.","content_html":"<p>CSV is a good format for sending a table to a person or another system. It is a bad format for asking the same table a hundred questions. Every query has to read all the text, split it at commas, find the quotes and convert the digits to numbers, and it does that again for each query. Parquet is the format most analytical tools use for the second job. This article converts one CSV into Parquet in one statement and then opens the file up to show what changed: the types, the layout, the compression and the statistics. Those four things are the entire reason for the speed-up, and understanding them tells you when the conversion is worth doing.</p>\n<h2>The conversion</h2>\n<p>With DuckDB, reading a CSV and writing Parquet is one <code>COPY</code> statement (the <a href=\"https://duckdb.org/docs/2.0/data/parquet/overview\" target=\"_blank\" rel=\"noopener noreferrer\">Parquet section of the DuckDB documentation</a> lists the options):</p>\n<pre><code class=\"language-bash\">head -n 3 work/orders-2m.csv\nduckdb -box -c \"DESCRIBE SELECT * FROM read_csv('work/orders-2m.csv')\"</code></pre>\n<pre><code class=\"language-text\">order_id,customer_id,zip,amount,created_at,status,country,note\n1,22313,99154,103.1,2025-11-22 13:08:24,shipped,GB,rush order\n2,193489,34494,31.59,2025-03-03 08:03:33,failed,BR,leave at door\n┌─────────────┬─────────────┬──────┬──────┬─────────┬───────┐\n│ column_name │ column_type │ null │ key  │ default │ extra │\n├─────────────┼─────────────┼──────┼──────┼─────────┼───────┤\n│ order_id    │ BIGINT      │ YES  │ NULL │ NULL    │ NULL  │\n│ customer_id │ BIGINT      │ YES  │ NULL │ NULL    │ NULL  │\n│ zip         │ VARCHAR     │ YES  │ NULL │ NULL    │ NULL  │\n│ amount      │ DOUBLE      │ YES  │ NULL │ NULL    │ NULL  │\n│ created_at  │ TIMESTAMP   │ YES  │ NULL │ NULL    │ NULL  │\n│ status      │ VARCHAR     │ YES  │ NULL │ NULL    │ NULL  │\n│ country     │ VARCHAR     │ YES  │ NULL │ NULL    │ NULL  │\n│ note        │ VARCHAR     │ YES  │ NULL │ NULL    │ NULL  │\n└─────────────┴─────────────┴──────┴──────┴─────────┴───────┘</code></pre>\n<p>The first part of the output is what DuckDB inferred from the CSV before writing anything. <code>order_id</code> and <code>customer_id</code> are <code>BIGINT</code>, <code>created_at</code> is <code>TIMESTAMP</code>, <code>zip</code>, <code>status</code>, <code>country</code> and <code>note</code> are <code>VARCHAR</code>, and **<code>amount</code> is <code>DOUBLE</code>**. Two of those are decisions you may not agree with. <code>zip</code> stayed text, rightly, because DuckDB saw leading zeros (<code>00500</code>). And <code>amount</code> became a floating-point number, though it holds money. A CSV carries no types, so the converter chooses them, and Parquet records the choice permanently. I come back to this below.</p>\n<p>Writing with different compression codecs:</p>\n<pre><code class=\"language-bash\">for c in snappy zstd gzip uncompressed; do\n  printf '%-13s' $c\n  s=$(date +%s.%N)\n  duckdb -c \"COPY (SELECT * FROM read_csv('work/orders-2m.csv')) TO 'work/pq/o_$c.parquet' (FORMAT parquet, COMPRESSION $c)\"\n  e=$(date +%s.%N)\n  printf '%10d bytes %6.2f s\\n' $(stat -c %s work/pq/o_$c.parquet) $(echo \"$e - $s\" | bc)\ndone\nls -l work/orders-2m.csv | awk '{printf \"%-13s%10d bytes (the CSV)\\n\", \"csv\", $5}'</code></pre>\n<pre><code class=\"language-text\">snappy         51477383 bytes   0.93 s\nzstd           34206903 bytes   0.59 s\ngzip           34908832 bytes   1.20 s\nuncompressed   75512763 bytes   0.68 s\ncsv           129919987 bytes (the CSV)</code></pre>\n<table><thead><tr><th>Codec</th><th>Size</th><th>Time</th></tr></thead><tbody><tr><td>none</td><td>75,512,763 bytes</td><td>0.68 s</td></tr><tr><td>snappy (DuckDB's default)</td><td>51,477,383 bytes</td><td>0.93 s</td></tr><tr><td>gzip</td><td>34,908,832 bytes</td><td>1.20 s</td></tr><tr><td>zstd</td><td>34,206,903 bytes</td><td>0.59 s</td></tr><tr><td>the CSV</td><td>129,919,987 bytes</td><td></td></tr></tbody></table>\n<p>Even without compression the Parquet file is 58% of the CSV, because numbers stored as binary integers and doubles take less space than their digits. Compression takes it to 26% with zstd. <code>zstd</code> was both the smallest and the fastest to write in this run, which is not a general law; compression ratios depend on the data. The same job through other libraries:</p>\n<pre><code class=\"language-bash\">/usr/bin/time -f \"pandas + pyarrow: %e s, peak %M KB\" python -c \"\nimport pandas as pd\npd.read_csv('work/orders-2m.csv').to_parquet('work/pq/o_pandas.parquet')\n\"\nls -l work/pq/o_pandas.parquet | awk '{print $5 \" bytes\"}'\n/usr/bin/time -f \"polars streaming: %e s, peak %M KB\" python -c \"\nimport polars as pl\npl.scan_csv('work/orders-2m.csv').sink_parquet('work/pq/o_polars.parquet')\n\"\nls -l work/pq/o_polars.parquet | awk '{print $5 \" bytes\"}'</code></pre>\n<pre><code class=\"language-text\">pandas + pyarrow: 2.82 s, peak 500296 KB\n49004545 bytes\npolars streaming: 1.12 s, peak 531992 KB\n35534323 bytes</code></pre>\n<p>pandas with pyarrow read the CSV into memory and wrote 49,004,545 bytes in 2.82 s with a 500,296 KB peak. polars' streaming sink did it in 1.12 s with 531,992 KB for 35,534,323 bytes. They are fine for this size and need the data in memory (pandas) or a streaming plan (polars); DuckDB's <code>COPY</code> is the shortest to write. For files larger than memory, see <a href=\"/library/reading-large-csv-chunks-pandas\">reading a large CSV in chunks</a> and the <a href=\"/library/converting-csv-parquet-duckdb\">DuckDB articles</a>.</p>\n<h2>What is inside the file</h2>\n<p>A <a href=\"https://parquet.apache.org/docs/\" target=\"_blank\" rel=\"noopener noreferrer\">Parquet</a> file is a sequence of <strong>row groups</strong>, and each row group stores each column as its own block. DuckDB can show the file's metadata:</p>\n<pre><code class=\"language-bash\">duckdb -box -c \"SELECT path_in_schema AS col, type, compression, encodings, total_compressed_size AS bytes, stats_min, stats_max FROM parquet_metadata('work/pq/o_snappy.parquet') WHERE row_group_id = 0\"\nduckdb -box -c \"SELECT count(DISTINCT row_group_id) AS row_groups, max(row_group_num_rows) AS rows_per_group FROM parquet_metadata('work/pq/o_snappy.parquet')\"</code></pre>\n<pre><code class=\"language-text\">┌─────────────┬────────────┬─────────────┬──────────────────┬────────┬─────────────────────┬─────────────────────┐\n│     col     │    type    │ compression │    encodings     │ bytes  │      stats_min      │      stats_max      │\n├─────────────┼────────────┼─────────────┼──────────────────┼────────┼─────────────────────┼─────────────────────┤\n│ order_id    │ INT64      │ SNAPPY      │ PLAIN            │ 492545 │ 1                   │ 122880              │\n│ customer_id │ INT64      │ SNAPPY      │ PLAIN            │ 583235 │ 1                   │ 249998              │\n│ zip         │ BYTE_ARRAY │ SNAPPY      │ PLAIN            │ 669825 │ 00500               │ 99997               │\n│ amount      │ DOUBLE     │ SNAPPY      │ PLAIN_DICTIONARY │ 339061 │ 0.08                │ 607.8               │\n│ created_at  │ INT64      │ SNAPPY      │ PLAIN            │ 911679 │ 2025-01-01 00:03:30 │ 2025-12-31 23:55:54 │\n│ status      │ BYTE_ARRAY │ SNAPPY      │ PLAIN_DICTIONARY │ 46672  │ failed              │ shipped             │\n│ country     │ BYTE_ARRAY │ SNAPPY      │ PLAIN_DICTIONARY │ 46655  │ BR                  │ US                  │\n│ note        │ BYTE_ARRAY │ SNAPPY      │ PLAIN_DICTIONARY │ 41200  │ call first          │ rush order          │\n└─────────────┴────────────┴─────────────┴──────────────────┴────────┴─────────────────────┴─────────────────────┘\n┌────────────┬────────────────┐\n│ row_groups │ rows_per_group │\n├────────────┼────────────────┤\n│ 17         │ 124226         │\n└────────────┴────────────────┘</code></pre>\n<p>The 2,000,000 rows are in 17 row groups of up to about 122,880 rows. For the first group, each column has its own type (<code>INT64</code>, <code>BYTE_ARRAY</code> for strings, <code>DOUBLE</code>), a compression codec (<code>SNAPPY</code>), an encoding and a size in bytes. Four things to read in it.</p>\n<p><strong>Column layout.</strong> Because <code>amount</code> is stored apart from <code>note</code>, a query that needs <code>amount</code> reads only the <code>amount</code> blocks. A CSV cannot do that; its columns are interleaved in every line, and a reader has to scan them all to find the fourth field in each.</p>\n<p><strong>Types.</strong> <code>order_id</code> is a 64-bit integer, 8 bytes for every value (the first group's block is 492,545 bytes, about 4 bytes a value after compression). The reader does not parse digits; it reads numbers.</p>\n<p><strong>Encoding.</strong> <code>status</code>, <code>country</code> and <code>note</code> use <code>PLAIN_DICTIONARY</code>: the file stores each distinct value once and the column as a list of small indexes. <code>status</code> has four distinct values, and its whole block for 122,880 rows is 46,672 bytes, about 3 bits a row. The text of those words costs 6 to 9 bytes a row in the CSV.</p>\n<p><strong>Statistics.</strong> Each row group records the minimum and maximum of each column. For <code>order_id</code> in the first group they are 1 and 122,880; for <code>status</code> they are <code>failed</code> and <code>shipped</code>. A query with <code>WHERE order_id BETWEEN 1000 AND 1100</code> can read the statistics and skip every row group whose range does not include the values. A CSV has no statistics; to know where order 1000 is, you read the file.</p>\n<h2>Does it make queries faster?</h2>\n<p>Three queries, run against the CSV (read with <code>read_csv</code>, which re-parses on every run) and against the Parquet file:</p>\n<pre><code class=\"language-bash\">Q1=\"SELECT status, count(*) AS n, round(sum(amount), 2) AS total FROM %s GROUP BY 1 ORDER BY 1\"\nfor src in \"read_csv('work/orders-2m.csv')\" \"'work/pq/o_snappy.parquet'\"; do\n  echo \"== $src\"\n  q=$(printf \"$Q1\" \"$src\")\n  time duckdb -c \"$q\" | head -n 12 | tail -n 4\ndone</code></pre>\n<pre><code class=\"language-text\">== read_csv('work/orders-2m.csv')\n│ pending  │ 400329 │  32077864.5 │\n│ refunded │ 400135 │ 32069815.84 │\n│ shipped  │ 399890 │ 32018038.17 │\n└──────────┴────────┴─────────────┘\n\nreal\t0m0.202s\nuser\t0m0.395s\nsys\t0m0.131s\n== 'work/pq/o_snappy.parquet'\n│ pending  │ 400329 │  32077864.5 │\n│ refunded │ 400135 │ 32069815.84 │\n│ shipped  │ 399890 │ 32018038.17 │\n└──────────┴────────┴─────────────┘\n\nreal\t0m0.070s\nuser\t0m0.084s\nsys\t0m0.047s</code></pre>\n<pre><code class=\"language-bash\">for src in \"read_csv('work/orders-2m.csv')\" \"'work/pq/o_snappy.parquet'\"; do\n  echo \"== $src: one column, max(amount)\"\n  time duckdb -noheader -list -c \"SELECT max(amount) FROM $src\"\ndone\necho; echo \"== a selective filter\"\nfor src in \"read_csv('work/orders-2m.csv')\" \"'work/pq/o_snappy.parquet'\"; do\n  echo \"$src\"\n  time duckdb -noheader -list -c \"SELECT count(*) FROM $src WHERE order_id BETWEEN 1000 AND 1100\"\ndone</code></pre>\n<pre><code class=\"language-text\">== read_csv('work/orders-2m.csv'): one column, max(amount)\n654.24\n\nreal\t0m0.203s\nuser\t0m0.323s\nsys\t0m0.137s\n== 'work/pq/o_snappy.parquet': one column, max(amount)\n654.24\n\nreal\t0m0.036s\nuser\t0m0.026s\nsys\t0m0.013s\n\n== a selective filter\nread_csv('work/orders-2m.csv')\n101\n\nreal\t0m0.191s\nuser\t0m0.334s\nsys\t0m0.127s\n'work/pq/o_snappy.parquet'\n101\n\nreal\t0m0.041s\nuser\t0m0.031s\nsys\t0m0.021s</code></pre>\n<table><thead><tr><th>Query</th><th>CSV</th><th>Parquet</th></tr></thead><tbody><tr><td><code>GROUP BY status</code> with <code>count</code> and <code>sum(amount)</code></td><td>0.202 s</td><td>0.070 s</td></tr><tr><td><code>max(amount)</code> (one column)</td><td>0.203 s</td><td>0.036 s</td></tr><tr><td><code>count(*) WHERE order_id BETWEEN 1000 AND 1100</code></td><td>0.191 s</td><td>0.041 s</td></tr></tbody></table>\n<p>A factor of three to six, with a caveat: <strong>these queries are fast on the CSV too</strong>. DuckDB's CSV reader is parallel and the file was in the operating system's cache, so 130 MB is parsed in 0.2 s. On a wider table, a larger file, a cold cache or a slower reader the gap grows, because the CSV cost scales with all the bytes and the Parquet cost with only the columns and row groups the query touches. <a href=\"/library/parquet-beats-csv-repeated-analysis\">A longer comparison</a> measures that on a bigger file. For a table you query once, converting costs more than it saves; for one you query repeatedly, you pay once.</p>\n<h2>The type decision</h2>\n<p>DuckDB made <code>amount</code> a <code>DOUBLE</code>, and a double cannot hold <code>103.10</code> exactly. For money, a decimal type is the right one, and the conversion can say so:</p>\n<pre><code class=\"language-bash\">duckdb -c \"COPY (SELECT order_id, customer_id, zip, CAST(amount AS DECIMAL(10,2)) AS amount, created_at, status, country, note FROM read_csv('work/orders-2m.csv')) TO 'work/pq/o_decimal.parquet' (FORMAT parquet, COMPRESSION zstd)\"\nls -l work/pq/o_decimal.parquet work/pq/o_zstd.parquet | awk '{printf \"%-22s %d bytes\\n\", $9, $5}' | sed 's#work/pq/##'\nduckdb -box -c \"SELECT column_name, column_type FROM (DESCRIBE SELECT * FROM 'work/pq/o_decimal.parquet') WHERE column_name = 'amount'\"\nduckdb -box -c \"SELECT amount FROM 'work/pq/o_decimal.parquet' WHERE order_id = 1\"</code></pre>\n<pre><code class=\"language-text\">o_decimal.parquet 33922831 bytes\no_zstd.parquet 34206903 bytes\n┌─────────────┬───────────────┐\n│ column_name │  column_type  │\n├─────────────┼───────────────┤\n│ amount      │ DECIMAL(10,2) │\n└─────────────┴───────────────┘\n┌────────┐\n│ amount │\n├────────┤\n│ 103.10 │\n└────────┘</code></pre>\n<p>With <code>CAST(amount AS DECIMAL(10,2))</code> the Parquet column is <code>DECIMAL(10,2)</code>, the value for order 1 reads back as <code>103.10</code>, and the zstd file is 33,922,831 bytes against 34,206,903 for the <code>DOUBLE</code> version. It is smaller, because a fixed-point decimal compresses better than 8-byte floats with irregular bits. Give identifiers, money and codes their types explicitly, pass the column types to the reader (<code>types=</code> in DuckDB, <code>dtype=</code> in pandas) instead of letting it guess, and keep the choice in the conversion script. The <a href=\"/library/csv-file-has-no-types\">CSV types article</a> explains why the guess is a guess.</p>\n<h2>Checking the conversion</h2>\n<p>Round-trip the data and compare it as sets of rows:</p>\n<pre><code class=\"language-bash\">duckdb -noheader -list -c \"SELECT count(*) FROM (SELECT * FROM read_csv('work/orders-2m.csv') EXCEPT SELECT * FROM 'work/pq/o_snappy.parquet')\"\nduckdb -noheader -list -c \"SELECT count(*) FROM (SELECT * FROM 'work/pq/o_snappy.parquet' EXCEPT SELECT * FROM read_csv('work/orders-2m.csv'))\"</code></pre>\n<pre><code class=\"language-text\">0\n0</code></pre>\n<p><code>EXCEPT</code> in both directions returned 0, so the CSV and the Parquet file hold the same 2,000,000 rows under the inferred types. It is a strong check for types and values at once, and it would fail if a <code>zip</code> had lost its leading zero or a timestamp had shifted. Also compare the row counts and keep the CSV's checksum with the Parquet file.</p>\n<h2>What you give up</h2>\n<p>Parquet is not readable in a text editor; you need a tool, and it is awkward to inspect on a machine without one. Appending a row means writing a new file, since the layout is immutable, so a stream of small writes produces a pile of small files that reads slowly; the usual answer is to batch and compact. Changing a column's type means rewriting. Not every tool reads it: older ETL software, some spreadsheets and plenty of scripts want CSV. And the format has its own complexity: row group size, codecs and dictionary limits all matter once the data is big.</p>\n<h2>How to tell it worked</h2>\n<p>Compare row counts and run the <code>EXCEPT</code> check. Look at the schema and confirm each identifier is text and each amount is a decimal. Query a few values and compare them with the CSV. Time one query you care about against both files, with a cold cache if you can, and decide whether the saving is worth the extra file. Note the conversion script and the source checksum, so the Parquet can be rebuilt.</p>\n<h2>When a CSV is still the better file</h2>\n<p>For a file that goes to a person, to a partner who does not use analytical tools, to a system that only speaks CSV, or into a repository where a diff has to be readable, keep CSV. For a table that is read once, or is small enough that the parse time does not register, the conversion buys nothing. For a data set that will be queried many times by columns and filters, convert it, put the types in the script and keep the CSV as the source. The <a href=\"/library/handling-csv-files-too-big\">large-CSV hub</a> puts this in the context of the other options.</p>","date_published":"2026-11-05T15:09:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/xml-good","url":"https://datahacks.dev/library/xml-good","title":"What XML Is Good At and Why It Still Shows Up in Your Data","summary":"A tested beginner guide to XML: where you will meet it in data work, what it does better than JSON, how it is structured, and the first checks to run.","content_html":"<p>You did not choose XML. It was in the spreadsheet you opened, the sitemap your site publishes, the feed a vendor sends every night, the config file of your build tool and the export that arrived with the words \"legacy system\" attached. XML is thirty years old, and it holds more of the world's structured information than any newer format. If you work with data, you will be asked to read it, and the first hour is the worst, because every tutorial assumes you already know what it is for.</p>\n<p>This guide is the orientation I would want: what XML is good at, where it hides, how it is structured, what goes wrong first, and where to go next. The examples are small files I ran through real tools, with the numbers the tools printed.</p>\n<h2>What XML actually is</h2>\n<p>XML, the Extensible Markup Language, is a way to write a tree as text. Everything in a document is one of a small set of things.</p>\n<p>An <strong>element</strong> is a named node, written with a start tag and an end tag: <code>&lt;price&gt;12.50&lt;/price&gt;</code>. Elements nest, and the nesting is the tree. A document has exactly one outermost element, the <strong>root</strong>.</p>\n<p>An <strong>attribute</strong> is a name and a value attached to an element's start tag: <code>&lt;price currency=\"USD\"&gt;</code>. It is one string, single per name, with no structure of its own.</p>\n<p><strong>Text</strong> is what is between the tags. <strong>Comments</strong>, <strong>processing instructions</strong> and <strong>CDATA sections</strong> are the rest. And the whole is usually opened by a <strong>declaration</strong> that names the version and the character encoding.</p>\n<pre><code class=\"language-xml\">&lt;?xml version=\"1.0\" encoding=\"UTF-8\"?&gt;\n&lt;catalog&gt;\n  &lt;book id=\"b1\" lang=\"en\"&gt;\n    &lt;title&gt;Cache Patterns&lt;/title&gt;\n    &lt;price currency=\"USD\"&gt;12.50&lt;/price&gt;\n  &lt;/book&gt;\n&lt;/catalog&gt;</code></pre>\n<p>That is all there is. The rest is conventions and standards built on top of this one idea: a document is a tree of named, ordered nodes, and any tool that can read the tree can read any XML. The grammar is strict, and a parser reads or refuses a document, which is why the same format has worked for decades.</p>\n<h2>What XML does well</h2>\n<p>Compared with JSON, which is the format most people reach for now, XML has strengths that are real and specific.</p>\n<p><strong>It handles text with markup.</strong> In <code>&lt;p&gt;Hello &lt;b&gt;big&lt;/b&gt; world&lt;/p&gt;</code> the bold word sits in the middle of a sentence. XML represents that exactly. JSON has no good way to. This is why books, articles, legal texts, manuals and web pages are marked up documents, not records, and why the formats that carry them (DocBook, DITA, EPUB, XHTML) are XML.</p>\n<p><strong>Order is data.</strong> Children appear in a defined sequence and a parser preserves it. A list of steps, a table of rows or a sentence of words has an order that matters, and XML keeps it without any convention.</p>\n<p><strong>It has namespaces.</strong> Two vocabularies can share one document without their element names colliding. An Atom feed can carry Dublin Core metadata and a media extension at once, each element tagged with the vocabulary it comes from (<a href=\"/library/xml-namespaces-work\">how namespaces work</a>).</p>\n<p><strong>It has mature validation.</strong> A DTD or an XML Schema can state what a document must look like, down to types and enumerations, and a tool checks it (<a href=\"/library/well-formed-valid-xml\">well formed versus valid</a>). Schematron adds business rules. Industries built contracts on that.</p>\n<p><strong>It has a toolchain for transformation and query.</strong> XPath selects nodes, XSLT turns one XML document into another document or into text, and XQuery queries collections. They are standard and have been stable for twenty years.</p>\n<p><strong>It carries comments and processing instructions.</strong> A config file can explain itself, and a document can carry directions to the program that reads it.</p>\n<p>Its weaknesses are the mirror image. It has no native numbers, booleans, null or arrays, so every value is text until a schema says otherwise. A list of one item is indistinguishable from a single item without the schema. It is verbose: the same 50,000 records were 6.40 MB as XML and 4.38 MB as JSON, and parsed about twice as slowly in Python (<a href=\"/library/xml-json-data-exchange\">XML versus JSON for data exchange</a> has the numbers and what a conversion loses). And it has traps for the careless: namespaces, entities and encodings.</p>\n<h2>A tour of where it hides</h2>\n<p>The quickest way to see why XML persists is to open some files. Each of these is a format you probably use already.</p>\n<h3>Your spreadsheet is a zip of XML</h3>\n<p>An <code>.xlsx</code> file is a zip archive. I generated a workbook with 2,000 rows and listed it:</p>\n<pre><code class=\"language-text\">[Content_Types].xml                  1167 bytes\n_rels/.rels                           587 bytes\nxl/_rels/workbook.xml.rels            697 bytes\nxl/worksheets/sheet1.xml           243612 bytes   (compressed 30946)\nxl/workbook.xml                       550 bytes\nxl/sharedStrings.xml                 1611 bytes\nxl/styles.xml                         867 bytes\nxl/theme/theme1.xml                  6994 bytes\ndocProps/core.xml                     592 bytes\ndocProps/app.xml                      784 bytes</code></pre>\n<p>Ten parts, every one of them XML. The data is in <code>sheet1.xml</code>, which has a <code>row</code> element per row and a <code>c</code> (cell) element per cell:</p>\n<pre><code class=\"language-xml\">&lt;row r=\"2\" spans=\"1:4\"&gt;&lt;c r=\"A2\"&gt;&lt;v&gt;1&lt;/v&gt;&lt;/c&gt;&lt;c r=\"B2\" t=\"s\"&gt;&lt;v&gt;4&lt;/v&gt;&lt;/c&gt;&lt;c r=\"C2\"&gt;&lt;v&gt;1.5&lt;/v&gt;&lt;/c&gt;&lt;/row&gt;</code></pre>\n<p>The attribute <code>t=\"s\"</code> means the value is an index into <code>sharedStrings.xml</code>, which holds each distinct string once (55 unique strings for 2,289 uses). The root element is in the namespace <code>http://schemas.openxmlformats.org/spreadsheetml/2006/main</code>, which is the thing to know before you write an XPath against it. The 2,000 rows made 243,612 bytes of XML that compressed to 30,946 inside the zip.</p>\n<p>That structure is the reason you can read a workbook without Excel. At 100,000 rows the sheet was 13.91 MB of XML in a 1.7 MB file. Reading it by streaming the XML with <code>lxml.iterparse</code> and summing a column took 0.64 s, against 1.72 s for <code>openpyxl</code> in read-only mode and 2.12 s for <code>pandas.read_excel</code>, which sits on top of an XML reader. The XML is not a thing to be afraid of. It is the fastest way into the file if you know what to look for, and the same structure holds for Word (<code>.docx</code>) and PowerPoint (<code>.pptx</code>).</p>\n<h3>Sitemaps, feeds and icons</h3>\n<p>A sitemap, the file that tells search engines your URLs, is XML in a namespace:</p>\n<pre><code class=\"language-xml\">&lt;urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"&gt;\n  &lt;url&gt;&lt;loc&gt;https://example.com/&lt;/loc&gt;&lt;lastmod&gt;2026-01-02&lt;/lastmod&gt;&lt;/url&gt;\n&lt;/urlset&gt;</code></pre>\n<p>An RSS or Atom feed is the same kind of file, with <code>item</code> or <code>entry</code> elements. An SVG image is XML in the SVG namespace, where a circle is <code>&lt;circle cx=\"32\" cy=\"32\" r=\"30\"/&gt;</code> and a shape's style is an attribute. All three were well formed under <code>xmllint</code>, and I read a value out of each with an XPath: the two sitemap URLs, the title <code>First &amp; best</code> of the feed item (note that the file says <code>&amp;amp;</code>, which the parser turns back into an ampersand), and <code>64</code> for the icon's width.</p>\n<h3>SOAP and enterprise integrations</h3>\n<p>SOAP web services, banking and insurance messages, healthcare documents and customs declarations are XML, defined by schemas that standards bodies publish. A request looks like this:</p>\n<pre><code class=\"language-xml\">&lt;soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:m=\"urn:example:orders\"&gt;\n  &lt;soap:Body&gt;&lt;m:GetOrder&gt;&lt;m:Id&gt;7&lt;/m:Id&gt;&lt;/m:GetOrder&gt;&lt;/soap:Body&gt;\n&lt;/soap:Envelope&gt;</code></pre>\n<p>Two namespaces, one for the envelope and one for the application. You will meet this when you integrate with a system that is older than your company. The contract is a schema file, and the first thing to do is validate your messages against it.</p>\n<h3>Build and configuration files</h3>\n<p>Maven's <code>pom.xml</code>, Android layouts and manifests, .NET project files, Spring configuration, Ant builds and macOS property lists are XML. The <code>pom.xml</code> I used declares a default namespace and an <code>xsi:schemaLocation</code> attribute that points to its schema, and its dependencies were readable with an XPath on the local names (<code>artifactId</code> returned <code>app</code> and <code>jackson-databind</code>). Config in XML has comments and a schema, which is why the format lasted there.</p>\n<h3>Everything else</h3>\n<p>Scientific data (GPX and KML for tracks and maps, CML for chemistry, SBML for biology), publishing (EPUB, DocBook, JATS for journals), finance (XBRL for company reports, FpML), government and legal data (AKN, XBRL again) and countless exports from software written between 1998 and 2015. If a document has structure that needs to be validated and kept for decades, someone probably chose XML for it.</p>\n<h2>First checks for a file you have never seen</h2>\n<p>When an XML file arrives, three checks save most of the pain.</p>\n<p><strong>Is it well formed?</strong> <code>xmllint --noout file.xml</code> prints nothing if it is, and one error line if it is not. The rules are short (one root, matched tags, quoted attributes, escaped <code>&amp;</code> and <code>&lt;</code>), and the error names the line. The <a href=\"/library/cheat-sheet-xml-syntax-rules\">cheat sheet of syntax rules</a> lists 51 cases with their messages, and <a href=\"/library/fixing-xml-parse-errors-fast\">fixing XML parse errors fast</a> goes through the failures you will see.</p>\n<p><strong>Does the encoding match the bytes?</strong> The declaration says what the file claims. A file whose bytes are UTF-8 but whose declaration says ISO-8859-1 parses without an error and gives <code>cafÃ©</code> (<a href=\"/library/xml-encoding-declarations-wrong-encoding\">the wrong encoding problem</a>). Compare the declaration with what <code>file</code> reports.</p>\n<p><strong>What namespace is it in?</strong> Look at the root element for <code>xmlns</code>. If there is a default namespace, every unprefixed element is in it, and a path like <code>//entry</code> finds nothing until you map a prefix. This single fact explains most \"my XPath returns nothing\" questions (<a href=\"/library/xml-namespaces-work\">namespaces</a>).</p>\n<p>After that, look at the structure. The <a href=\"/tools/xml-formatter\">XML formatter</a> shows a document indented, and <code>xmllint --format file.xml</code> does the same on the command line. Find the repeating element (<code>row</code>, <code>item</code>, <code>order</code>): that is your record.</p>\n<h2>Getting data out</h2>\n<p>There are three levels of tool, and you will use all of them.</p>\n<p><strong>Query it where it is.</strong> XPath selects nodes: <code>//item[price&gt;10]/title</code>. In the command line <code>xmllint --xpath</code> and in code <code>lxml</code>'s <code>.xpath()</code> do it. Ten expressions cover most needs (<a href=\"/library/xml-xpath-queries-cover-most-real\">XPath queries that cover most real needs</a>), and the <a href=\"/tools/xpath-tester\">XPath tester</a> lets you try one on a sample.</p>\n<p><strong>Read it in code.</strong> Python's standard library has <code>xml.etree.ElementTree</code>, and <code>lxml</code> is the faster and fuller library (<a href=\"/library/parsing-writing-xml-python\">parsing and writing XML in Python</a>). For large files, stream with <code>iterparse</code> and clear nodes as you go, which handled a 14 MB sheet in well under a second (<a href=\"/library/parsing-large-xml-files-iterparse\">parsing large XML files with iterparse</a>). For untrusted input, use <code>defusedxml</code> (<a href=\"/library/safe-xml-parsing-python-defusedxml\">safe XML parsing in Python</a>).</p>\n<p><strong>Load it into a database.</strong> PostgreSQL has an <code>xml</code> type and <code>xmltable</code>, SQL Server has <code>nodes()</code> and <code>value()</code>, Oracle has <code>XMLTABLE</code>, and MySQL has <code>ExtractValue</code> and <code>LOAD XML</code> (<a href=\"/library/storing-querying-xml-inside-database\">storing and querying XML inside a database</a>). When the destination is tables, the pattern is to find the repeating element and turn each into a row (<a href=\"/library/turning-xml-feeds-relational-tables\">turning XML feeds into relational tables</a>).</p>\n<h2>The common first mistakes</h2>\n<p>A short list of what bites newcomers, each covered in a spoke.</p>\n<ol><li><p>**A raw <code>&amp;</code> or <code>&lt;</code> in text** from string concatenation. Use a serializer (<a href=\"/library/xml-entities-cdata-escaping-special\">escaping special characters</a>).</p></li><li><p><strong>A path that finds nothing</strong> because of a default namespace.</p></li><li><p><strong>HTML entities</strong> like <code>&amp;nbsp;</code> in XML, which are undefined.</p></li><li><p><strong>Treating indentation as data.</strong> Whitespace between elements is text in the tree (<a href=\"/library/whitespace-xml-sometimes-matters\">why whitespace sometimes matters</a>).</p></li><li><p><strong>Trusting the encoding declaration.</strong></p></li><li><p><strong>Parsing untrusted XML with a parser that resolves entities.</strong> A DTD can read files or expand a few hundred bytes into gigabytes (<a href=\"/library/xml-dtd\">what a DTD is</a>). Reject documents that carry a DOCTYPE unless you need one.</p></li><li><p><strong>Assuming a list.</strong> An element that repeats is a list; one that appears once is not, unless you handle it.</p></li></ol>\n<h2>Choosing XML, or not</h2>\n<p>If you are designing a new format for records between systems you control, use JSON. It is smaller, faster and simpler. If your content is documents with inline structure, a long-lived archive with strict validation, a vocabulary shared by many parties, or an existing standard, use XML. When a counterparty decides for you, learn to read it well; it is easier than its reputation.</p>\n<h2>A learning path through this cluster</h2>\n<p>The articles on this site that follow the guide fall into five groups. The basics (this guide plus the nine that follow it) explain the syntax, validity, attributes, namespaces, escaping, whitespace, encodings, DTDs and oddities. Debugging (<a href=\"/library/fixing-xml-parse-errors-fast\">fixing XML parse errors fast</a>) works through each error message and its cause. XPath (<a href=\"/library/xml-xpath-queries-cover-most-real\">XPath queries that cover most real needs</a>) covers selecting nodes. Python (<a href=\"/library/parsing-writing-xml-python\">parsing and writing XML in Python</a>) shows the libraries. And the database and pipeline groups show how to get XML into tables.</p>\n<p>The primary references are the <a href=\"https://www.w3.org/TR/xml/\" target=\"_blank\" rel=\"noopener noreferrer\">XML 1.0 recommendation</a>, which is short and readable, and the <a href=\"https://www.w3.org/TR/xml-names/\" target=\"_blank\" rel=\"noopener noreferrer\">Namespaces in XML</a> recommendation. The libxml2 tools used above are documented at the <a href=\"https://gitlab.gnome.org/GNOME/libxml2/-/wikis/home\" target=\"_blank\" rel=\"noopener noreferrer\">GNOME libxml2 wiki</a>.</p>\n<h2>A short history, and why the format stayed</h2>\n<p>XML came out of SGML, the heavyweight markup language used for technical publishing, and the W3C published XML 1.0 as a recommendation in 1998. The goal was a simpler SGML that could travel on the web: strict enough for programs to parse with no guessing, flexible enough to define any vocabulary. Within a few years it was everywhere, as the format for configuration, for web services (SOAP and its relatives), for office documents and for data exchange between companies.</p>\n<p>Then JSON arrived and took the part of the job that was always awkward in XML: sending records between a browser and a server. For that, JSON is smaller, simpler and closer to the data structures programs already use. XML did not go away, because it was never only a record format. The standards written in it were expensive to write and are expensive to replace: tax filings, healthcare messages, publishing pipelines, mapping data, accounting formats. The tools (parsers, validators, XSLT engines) are stable, free and in every language. A format that works and is already specified in your industry is hard to dislodge.</p>\n<p>So the practical position is: you will not often choose XML for new work, and you will often be handed it.</p>\n<h2>Reading a spreadsheet's XML yourself</h2>\n<p>The workbook example is worth doing once with your own hands, because it shows the whole workflow in a few lines. A sheet is a stream of <code>row</code> elements. Streaming them with <code>iterparse</code>, and clearing each row once it has been handled, keeps memory flat however many rows there are:</p>\n<pre><code class=\"language-python\">M = \"{http://schemas.openxmlformats.org/spreadsheetml/2006/main}\"\nwith zipfile.ZipFile(\"big.xlsx\").open(\"xl/worksheets/sheet1.xml\") as f:\n    for event, el in etree.iterparse(f, tag=M + \"row\"):\n        for c in el:\n            if c.get(\"r\", \"\").startswith(\"C\"):\n                total += float(c.find(M + \"v\").text)\n        el.clear()\n        while el.getprevious() is not None:\n            del el.getparent()[0]</code></pre>\n<p>Three things in it are the lessons of this guide in miniature. The tag is written with the namespace in curly braces, because the elements are in the spreadsheet namespace. The loop reads cells by their <code>r</code> attribute (<code>C2</code>, <code>C3</code>) because the document is a tree of named things and not a grid. And the clearing at the end keeps the tree from growing: without it the same loop would hold the whole sheet in memory. For my 100,000 row file the sum of the third column came out as 5,000,000.0 in 0.64 s.</p>\n<p>Note that strings in a workbook may be stored inline or in <code>sharedStrings.xml</code>, depending on the program that wrote the file, and a cell with <code>t=\"s\"</code> holds an index, not the text. If you are reading real files from Excel and not generated ones, reach for a library (<code>openpyxl</code>, <code>pandas</code>) and keep the raw approach for when the library is too slow or too memory-hungry.</p>\n<h2>What a first project looks like</h2>\n<p>The usual first task is turning an XML feed into a table. With the sitemap and the RSS feed from above, it took a few lines:</p>\n<pre><code class=\"language-python\">ns = {\"s\": \"http://www.sitemaps.org/schemas/sitemap/0.9\"}\nrows = [{\"loc\": u.findtext(\"s:loc\", namespaces=ns),\n         \"lastmod\": u.findtext(\"s:lastmod\", namespaces=ns),\n         \"changefreq\": u.findtext(\"s:changefreq\", namespaces=ns)}\n        for u in tree.iterfind(\"s:url\", ns)]\npd.DataFrame(rows)</code></pre>\n<pre><code class=\"language-text\">                      loc    lastmod changefreq\n     https://example.com/ 2026-01-02        NaN\nhttps://example.com/about        NaN    monthly</code></pre>\n<p>Two points show up immediately. Optional elements become missing values (<code>lastmod</code> is absent on the second URL, so <code>findtext</code> returned <code>None</code>), which is the XML way of saying \"not provided\". And everything is text: the RSS publication date <code>Fri, 02 Jan 2026 03:04:05 GMT</code> needed an explicit format string to become a timestamp, <code>2026-01-02 03:04:05+00:00</code>. Pandas can do the whole thing in one call, <code>pd.read_xml(\"sitemap.xml\", xpath=\".//s:url\", namespaces=ns)</code>, which gave the same table with the <code>priority</code> column included. Without the namespace mapping it raised <code>ValueError: xpath does not return any nodes</code>: the default-namespace trap in its most common form.</p>\n<p>That is the whole shape of the work. Find the repeating element, name its namespace, take the fields you want, convert the types, and decide what a missing element means.</p>\n<h2>What it costs</h2>\n<p>Numbers from the other articles in this cluster give a feel for the practical costs. The same 50,000 orders took 3.83 MB written with attributes, 5.88 MB with child elements and 7.43 MB indented, and all of them compressed to between 0.25 and 0.28 MB, so size matters in memory and on disk but not on the wire (<a href=\"/library/xml-attributes-child-elements\">attributes versus child elements</a>). Parsing them took 0.09 to 0.32 s depending on the parser and the layout. The parse of a 6.4 MB document took 0.109 s in <code>lxml</code> and 0.174 s in ElementTree. Neither is slow, and both are slower than the equivalent JSON.</p>\n<p>The costs that hurt are not speed. They are the hours spent on a namespace, an encoding or an entity. The cheat sheet, the encoding article and the entity article exist for those.</p>\n<h2>Security in one page</h2>\n<p>XML parsers have a history of vulnerabilities, and the cause is a feature: the DTD. A document can declare entities, and an entity can point at a file (<code>file:///etc/hostname</code>) or a URL, or expand a few hundred bytes into hundreds of thousands of characters. Current ElementTree and <code>lxml</code> refuse to read external files by default and cap entity expansion: an eight-level expansion was rejected by both. A parser with entity resolution switched on read the file's contents, and <code>defusedxml</code> rejected every document that declared an entity at all.</p>\n<p>The rule for anything from outside your own systems is to use a parser configured for untrusted input, to reject documents with a <code>DOCTYPE</code>, and to limit size and depth before parsing, as for any format (<a href=\"/library/xml-dtd\">what a DTD is and why you rarely need one</a>, <a href=\"/library/safe-xml-parsing-python-defusedxml\">safe XML parsing in Python</a>). The same attitude toward untrusted input that applies to JSON (<a href=\"/library/json-security-mistakes-apps\">JSON security mistakes in apps and pipelines</a>) applies here, and XML adds the entity features to the list.</p>\n<h2>The vocabulary you will keep running into</h2>\n<p>A handful of terms recur in every XML conversation, and knowing them makes documentation readable.</p>\n<p>A <strong>node</strong> is any item in the tree: an element, an attribute, a piece of text, a comment. The <strong>root</strong> is the single outermost element. <strong>Well formed</strong> means it obeys the syntax. <strong>Valid</strong> means it also follows a schema.</p>\n<p>A <strong>namespace</strong> is a URI that scopes names, and a <strong>prefix</strong> is a local alias for it. The URI is the identity and the prefix is a convenience. A <strong>default namespace</strong> is one declared without a prefix, which applies to unprefixed elements.</p>\n<p>A <strong>DTD</strong> is the old schema language, and <strong>XSD</strong> (XML Schema) the newer one, written in XML itself. <strong>Schematron</strong> states rules as XPath assertions. <strong>RELAX NG</strong> is a third, less common alternative.</p>\n<p><strong>XPath</strong> is the language for selecting nodes. <strong>XSLT</strong> transforms a document by applying templates, and <strong>XQuery</strong> queries collections the way SQL queries tables.</p>\n<p><strong>DOM</strong> is the model where a parser loads the whole tree into memory and you walk it. <strong>SAX</strong> and <strong>StAX</strong> are streaming models that report events as the parser reads, so memory stays small. ElementTree and <code>lxml</code> are DOM-like with a streaming option (<code>iterparse</code>) that gives you the middle path: events for each element, with the subtree available while you work on it.</p>\n<p>An <strong>entity</strong> is a named piece of text, and a <strong>character reference</strong> (<code>&amp;#169;</code>) is a numeric way to write a character. <strong>CDATA</strong> is a section where markup characters are literal.</p>\n<p>Finally, <strong>canonical XML</strong> (c14n) is a defined way to write a document so that two logically equal documents have identical bytes. It is what digital signatures use, and what you would use to compare two documents byte for byte if layout differences should not count.</p>\n<p>None of these needs mastering before you start. They are the words that explain an error message, and now you can look each one up.</p>\n<h2>What to learn next, in order of payoff</h2>\n<p>If I had one week to become useful with XML, I would spend the first day on XPath, because selecting the right nodes is most of the job. The second day goes to a library in the language I use, reading, writing and streaming. The third day is for namespaces and encodings, which are where files go wrong. The fourth is validation: running a file against a supplied XSD with <code>xmllint</code> and reading the errors. The fifth day is for getting data into the place it is needed, tables or JSON, with a plan for missing elements and repeated ones. After that, XSLT is worth a look if you convert documents often, and Schematron if you own a format. Everything on that list has an article here, in this cluster, with the commands run and the output shown.</p>\n<h2>How to approach the cluster</h2>\n<p>If you are new, read in this order: the syntax cheat sheet, well-formed versus valid, namespaces, escaping, and the encoding article. Those five remove most of the first-day problems. Then take whichever of debugging, XPath or Python matches what you are doing, and come back to the database articles when you need to load the data into tables. Each article is one idea with one or two experiments you can repeat; every command above ran as shown.</p>\n<h2>When XML is not the format you need</h2>\n<p><strong>You are designing a record exchange between your own services.</strong> JSON is smaller, faster to parse and has the types you want. Do not take on namespaces and schemas without a reason.</p>\n<p><strong>The data is tabular.</strong> A CSV or a Parquet file is the right shape for rows and columns. XML would add size and a mapping step. If you receive XML that is really a table, convert it once and work with the table.</p>\n<p><strong>You need human-edited configuration.</strong> YAML and TOML are friendlier to type than XML for small configs, though XML offers comments and validation too.</p>\n<p><strong>Speed and size are the constraint.</strong> Binary formats and JSON Lines beat XML for high-volume data. Keep XML for the cases where its features are the point.</p>","date_published":"2026-11-05T00:17:00+00:00","tags":["XML"]},{"id":"https://datahacks.dev/library/inside-excel-file","url":"https://datahacks.dev/library/inside-excel-file","title":"What Is Inside an Excel File and What It Means for Data","summary":"See what an XLSX workbook really contains, from cell types and shared strings to dates, formulas, styles and limits, and what each part means for your data.","content_html":"<p>An Excel file looks like a grid, and most people treat it as one: rows, columns, values. A program that reads the file sees something different. It sees a package of XML documents in which the grid is only one part, where a cell may hold a pointer to a string stored elsewhere, where a date is a number in disguise and a formula is a recipe plus a remembered answer. Nearly every Excel data problem you will meet comes from the gap between the grid and the package: the zip code that lost its leading zero, the date that arrives as 45292, the total that is empty when a script reads it, the column that is text in one row and a number in the next.</p>\n<p>This guide opens the package and explains what is in it, in the order that matters for people who move data in and out of spreadsheets. It is the map for the Excel cluster on this site. Each section points to a focused article, and the later clusters on <a href=\"/library/getting-excel-data-database\">loading Excel data into a database</a>, <a href=\"/library/sending-database-results-excel\">sending database results to Excel</a>, <a href=\"/library/reading-writing-excel-files-openpyxl\">openpyxl</a>, <a href=\"/library/working-excel-pandas\">pandas</a>, <a href=\"/library/excel-dates-numbers-text\">dates and types</a> and <a href=\"/library/translating-excel-formulas-sql\">translating formulas into SQL</a> build on it.</p>\n<h2>A workbook is a zip file</h2>\n<p>Rename <code>report.xlsx</code> to <code>report.zip</code> and open it with any archive tool. It opens, because an XLSX file is a ZIP archive. Inside are folders and XML files arranged according to the Open Packaging Conventions, a standard for bundling the parts of a document that Microsoft Office, and several other formats, share. A small workbook contains roughly this:</p>\n<pre><code class=\"language-text\">[Content_Types].xml\n_rels/.rels\ndocProps/core.xml\ndocProps/app.xml\nxl/workbook.xml\nxl/_rels/workbook.xml.rels\nxl/worksheets/sheet1.xml\nxl/sharedStrings.xml\nxl/styles.xml\nxl/theme/theme1.xml</code></pre>\n<p>Each file has a job. <code>[Content_Types].xml</code> declares what type each part is. The <code>_rels</code> folders hold relationships, which tell a reader that sheet 1 is the file at <code>xl/worksheets/sheet1.xml</code>. <code>xl/workbook.xml</code> lists the sheets with their names, visibility and the date system. <code>xl/worksheets/</code> holds one XML file per sheet with the cell data. <code>xl/sharedStrings.xml</code> holds the text values. <code>xl/styles.xml</code> holds fonts, fills, borders and, importantly for data, the number formats. Larger workbooks add folders for tables, charts, drawings, images, comments, defined names and a calculation chain.</p>\n<p>The practical consequence is that nothing about the file is hidden. If an import tool misreads a value, you can unzip the file and look at the XML the tool read. The article <a href=\"/library/excel-xlsx-file-zip-xml\">an XLSX file is a zip of XML</a> walks through doing exactly that, and a short script gives a quick inventory:</p>\n<pre><code class=\"language-python\">import zipfile\n\nwith zipfile.ZipFile(\"report.xlsx\") as z:\n    for info in z.infolist():\n        print(f\"{info.file_size:&gt;10}  {info.compress_size:&gt;10}  {info.filename}\")</code></pre>\n<p>The two size columns show the uncompressed and compressed size of each part. For a workbook with a lot of data, the <code>sheet</code> files dominate. For one that is oddly large, a <code>media</code> folder or a huge <code>styles.xml</code> is the usual explanation, as covered in <a href=\"/library/excel-files-get-huge\">why Excel files get huge</a>.</p>\n<p>The older <code>.xls</code> format is not a zip and not XML. It is a binary format from before 2007, and it has tighter limits. The differences between the three formats you will meet, <code>.xls</code>, <code>.xlsx</code> and <code>.xlsb</code>, are in <a href=\"/library/excel-xls-xlsx-xlsb\">XLS versus XLSX versus XLSB</a>.</p>\n<h2>What a sheet stores</h2>\n<p>Open <code>xl/worksheets/sheet1.xml</code> and you find a <code>sheetData</code> element that holds rows, each holding cells. Here is a minimal example with a header and two data rows:</p>\n<pre><code class=\"language-xml\">&lt;worksheet&gt;\n  &lt;sheetData&gt;\n    &lt;row r=\"1\"&gt;\n      &lt;c r=\"A1\" t=\"s\"&gt;&lt;v&gt;0&lt;/v&gt;&lt;/c&gt;\n      &lt;c r=\"B1\" t=\"s\"&gt;&lt;v&gt;1&lt;/v&gt;&lt;/c&gt;\n      &lt;c r=\"C1\" t=\"s\"&gt;&lt;v&gt;2&lt;/v&gt;&lt;/c&gt;\n    &lt;/row&gt;\n    &lt;row r=\"2\"&gt;\n      &lt;c r=\"A2\" t=\"s\"&gt;&lt;v&gt;3&lt;/v&gt;&lt;/c&gt;\n      &lt;c r=\"B2\"&gt;&lt;v&gt;19.99&lt;/v&gt;&lt;/c&gt;\n      &lt;c r=\"C2\" s=\"1\"&gt;&lt;v&gt;45292&lt;/v&gt;&lt;/c&gt;\n    &lt;/row&gt;\n    &lt;row r=\"3\"&gt;\n      &lt;c r=\"A3\" t=\"s\"&gt;&lt;v&gt;4&lt;/v&gt;&lt;/c&gt;\n      &lt;c r=\"B3\"&gt;&lt;v&gt;5&lt;/v&gt;&lt;/c&gt;\n      &lt;c r=\"C3\" s=\"1\"&gt;&lt;v&gt;45293&lt;/v&gt;&lt;/c&gt;\n    &lt;/row&gt;\n  &lt;/sheetData&gt;\n&lt;/worksheet&gt;</code></pre>\n<p>A cell is a <code>c</code> element with a reference <code>r</code> such as <code>B2</code>, an optional type <code>t</code>, an optional style index <code>s</code>, and a value <code>v</code>. Three things deserve attention.</p>\n<p>First, only cells that hold something are written. An empty cell has no element at all, so the file does not store a rectangle. A reader must decide what to do about the gaps, and different libraries return <code>None</code>, <code>NaN</code> or an empty string. Row elements are skipped the same way when the whole row is empty.</p>\n<p>Second, the type attribute is small. With no <code>t</code>, the value is a number. <code>t=\"s\"</code> means the value is an index into the shared strings table. <code>t=\"b\"</code> is a boolean, <code>t=\"e\"</code> an error such as <code>#DIV/0!</code>, <code>t=\"str\"</code> a string that came from a formula, and <code>t=\"inlineStr\"</code> a string stored right in the cell, which some writers prefer. There is a <code>t=\"d\"</code> for ISO dates that Excel itself rarely writes. There is no text type separate from strings, and there is no date type.</p>\n<p>Third, the style index <code>s</code> points into <code>styles.xml</code>. The date cell <code>C2</code> holds the plain number 45292 and a style with index 1, and that style says \"format as a date\". Strip the style and the cell is a number.</p>\n<p>Everything a program reads from a sheet comes from these elements. The article <a href=\"/library/cheat-sheet-excel-data-types\">cheat sheet of Excel data types as programs see them</a> lists each case with what openpyxl and pandas return for it.</p>\n<h2>Text lives in a shared table</h2>\n<p>The sample above shows <code>&lt;v&gt;0&lt;/v&gt;</code> for the header cells, with <code>t=\"s\"</code>. The zero is not a value; it is a position in the shared strings table:</p>\n<pre><code class=\"language-xml\">&lt;sst count=\"5\" uniqueCount=\"5\"&gt;\n  &lt;si&gt;&lt;t&gt;product&lt;/t&gt;&lt;/si&gt;\n  &lt;si&gt;&lt;t&gt;price&lt;/t&gt;&lt;/si&gt;\n  &lt;si&gt;&lt;t&gt;ordered&lt;/t&gt;&lt;/si&gt;\n  &lt;si&gt;&lt;t&gt;notebook&lt;/t&gt;&lt;/si&gt;\n  &lt;si&gt;&lt;t&gt;pen&lt;/t&gt;&lt;/si&gt;\n&lt;/sst&gt;</code></pre>\n<p>Each unique string is stored once, and each cell that uses it points to its index. A column of one hundred thousand rows with ten distinct values stores ten strings and a hundred thousand small numbers. That saves space, and it is why a reader must load the whole shared strings table before it can interpret any cell. It is also why streaming readers need care: they hold the strings in memory even when they stream the cells (<a href=\"/library/reading-large-excel-files-read\">reading large Excel files in read-only mode</a>).</p>\n<p>A string that has partial formatting, such as one bold word, is stored as rich text with several runs. Most data libraries flatten it to plain text and discard the formatting. A string can hold up to 32,767 characters, and a cell that holds more is truncated by Excel on save.</p>\n<p>One more consequence: text that looks like a number is still text if the cell says so. The zip code <code>01234</code> typed into a cell formatted as text is stored as a shared string, and it stays <code>01234</code>. Typed into a general cell, Excel converts it to the number 1234 on entry, and the zero is gone before any file is written (<a href=\"/library/keeping-leading-zeros-excel\">keeping leading zeros</a>).</p>\n<h2>Numbers are doubles with a display format</h2>\n<p>A number cell stores an IEEE 754 double-precision value. This gives about 15 to 17 significant decimal digits, and Excel displays and keeps only 15. Anything beyond is rounded when the number is typed. A 16-digit credit card number typed as a number loses its last digit to zero, and nothing recovers it. Large identifiers belong in text cells (<a href=\"/library/excel-number-precision-15-digit\">Excel number precision and the 15 digit limit</a>). Integers are exact up to 2 to the power 53, which is 9,007,199,254,740,992, but the 15-digit display and entry limit applies first.</p>\n<p>Integers and decimals are the same type. The value <code>5</code> and the value <code>5.0</code> are one number, stored as <code>5</code>. A reader library decides whether to hand your code an integer or a float, and the choice differs. openpyxl returns an <code>int</code> when the stored text has no decimal point and a <code>float</code> otherwise; pandas often returns floats for a whole column if any cell has a decimal or is empty. A column of ids with one missing value arrives as floats such as <code>17.0</code> unless you ask for a nullable integer type.</p>\n<p>Percentages, currency and scientific notation are all formats on top of the same number. A cell shows <code>45%</code> while the stored value is <code>0.45</code>. A cell shows <code>$1,234.50</code> while the stored value is <code>1234.5</code>. The format lives in <code>styles.xml</code>, and a program that reads the value gets <code>0.45</code> and <code>1234.5</code>, which is usually what you want. It is a surprise only when you compare a screenshot with the output.</p>\n<h2>Dates are numbers</h2>\n<p>In the sheet XML above, <code>C2</code> holds <code>45292</code> with a style. Excel stores a date as the number of days since an epoch, with the time of day as the fraction of a day. The value 45292 is 1 January 2024 in the default Windows date system. The value 45292.5 is noon on that day. The number format on the cell, a custom format like <code>yyyy-mm-dd</code>, makes it display as a date. If you change the format to General, the cell shows 45292.</p>\n<p>This matters whenever data leaves Excel. A reader that does not look at the number format sees only a number, and your table gets a column of five-digit integers. Libraries differ: openpyxl checks the format and returns a Python <code>datetime</code> for cells whose format looks like a date, while a raw read of the XML gives you the number. When a date column arrives as numbers, the fix is a conversion, covered in <a href=\"/library/excel-stores-dates-serial-numbers\">how Excel stores dates as serial numbers</a>, with versions for <a href=\"/library/converting-excel-serial-dates-real\">SQL</a> and <a href=\"/library/converting-excel-serial-dates-python\">Python</a>.</p>\n<p>Two further details shape every conversion. The workbook has a date system flag in <code>workbook.xml</code>, <code>date1904</code>, which shifts every date by 1,462 days when set. Files created in very old versions of Excel for Mac use it (<a href=\"/library/mac-windows-date-systems-excel\">Mac and Windows date systems</a>). And Excel treats 1900 as a leap year, which it was not, so serial numbers before 1 March 1900 are off by a day compared with the real calendar (<a href=\"/library/excel-1900-leap-year-bug\">the 1900 leap year bug</a>). For dates after February 1900, a conversion that counts from 30 December 1899 is correct.</p>\n<h2>Formulas keep a recipe and an answer</h2>\n<p>A formula cell stores both:</p>\n<pre><code class=\"language-xml\">&lt;c r=\"D2\"&gt;&lt;f&gt;B2*C2&lt;/f&gt;&lt;v&gt;99.95&lt;/v&gt;&lt;/c&gt;</code></pre>\n<p>The <code>f</code> element is the formula text, without the leading equals sign. The <code>v</code> element is the last value Excel calculated, the cached value. A program that reads the file normally gets one or the other depending on how it is configured. pandas reads the cached values. openpyxl returns the formula text unless you open the workbook with <code>data_only=True</code>, in which case it returns the cached value (<a href=\"/library/excel-reading-only-values\">reading only the values and not the formulas</a>).</p>\n<p>The cached value is only as fresh as the last time the file was saved by a program that calculates. A workbook written by a script that adds formulas has no cached values at all, since the script does not evaluate them. Open the file in Excel, save, and the values appear. Until then, anything that reads cached values sees empty cells in every formula column. The explanation, with the consequences for pipelines, is in <a href=\"/library/happens-formulas-code-reads\">what happens to formulas when code reads an Excel file</a>, and the handling in pandas in <a href=\"/library/handling-excel-formulas-cached-values\">formulas and cached values in pandas</a>.</p>\n<p>Formulas can also refer to other workbooks, volatile functions that change on every open (<code>NOW</code>, <code>RAND</code>) and ranges that grow. Treat a formula column as a derived column whose value depends on when it was last calculated, and when exactness matters, recompute the numbers yourself in SQL or Python from the inputs.</p>\n<h2>Styles decide what you see</h2>\n<p><code>xl/styles.xml</code> holds the number formats, fonts, fills, borders and cell style definitions. The sheet cells carry only an index. For data work the number formats are the part that matters: they are the reason a number looks like a date, a percentage, a currency or a phone number. A style can also hide a value completely: the format <code>;;;</code> displays nothing at all while the value remains in the cell. A white font on a white fill does the same.</p>\n<p>A direct consequence: what you see can differ from what you read, on purpose. A cell showing <code>1,235</code> may hold <code>1234.567</code>. A cell showing <code>12/01/2024</code> may be 1 December or 12 January depending on the locale, and the stored number is the same either way. When you reconcile numbers between a spreadsheet and a database, compare the stored values and not the displayed ones.</p>\n<p>Conditional formatting, data validation drop-downs and cell comments are stored in the sheet XML or in separate parts. Libraries ignore most of them by default. If a person encodes meaning in color, such as a red row for a canceled order, that meaning is lost in an import, because the color is a style and the style is not data. Ask for a column.</p>\n<h2>Merged cells, hidden rows and other structure</h2>\n<p>A sheet may contain structure that has no equivalent in a table. Merged cells are stored as a list of ranges:</p>\n<pre><code class=\"language-xml\">&lt;mergeCells count=\"1\"&gt;&lt;mergeCell ref=\"A1:C1\"/&gt;&lt;/mergeCells&gt;</code></pre>\n<p>Only the top-left cell of a merged range holds a value. The other cells are empty in the XML. A report with a merged title across the top, or a merged region label that spans several rows, imports with blanks where the person saw one big cell. Fill the merged value downward or across before analysis (<a href=\"/library/excel-merged-cells-hidden-rows\">merged cells, hidden rows and other things that break imports</a>, and <a href=\"/library/excel-handling-merged-cells-reading-openpyxl\">handling merged cells with openpyxl</a>).</p>\n<p>Hidden rows and columns still exist in the data, so an import brings them in. A person who hid 40 rows to tidy the view may not realize that the totals in your table include them. Filters work the same way: the filter hides rows and leaves them in the file. Sheets themselves can be visible, hidden or \"very hidden\", the last of which the Excel interface does not list. A pipeline that reads \"all sheets\" reads those too.</p>\n<p>Title rows, subtotals, blank spacer rows and notes at the bottom are not structure the format enforces; they are habits. A sheet is free-form, and a table with a header on row 1 is a convention. Treat every sheet as unstructured until you have looked at it. The import articles show how to cope: <a href=\"/library/handling-excel-files-title-rows\">handling title rows above the header</a> and <a href=\"/library/skipping-header-junk-reading-excel\">skipping header junk with pandas</a>.</p>\n<h2>Tables and named ranges</h2>\n<p>Excel has two features that bring real structure. A table, created with \"Format as Table\", is stored in its own part as a named range with a header row, column names and a defined extent. Reading a table by name avoids guessing where the data ends (<a href=\"/library/reading-excel-tables-name-openpyxl\">reading Excel tables by name with openpyxl</a>). A defined name labels a cell or a range, such as <code>TaxRate</code> or <code>Orders</code>, and formulas can use it. Both are metadata in <code>workbook.xml</code> and the table parts, and neither is visible to a reader that only walks the cells. The explanation for developers is in <a href=\"/library/excel-named-ranges-tables-explained\">Excel named ranges and tables</a>.</p>\n<p>If you control the file, ask for a table. It makes the import code shorter and removes the guess about where the data stops.</p>\n<h2>Limits worth remembering</h2>\n<p>An XLSX sheet holds at most 1,048,576 rows and 16,384 columns, which is the range from <code>A1</code> to <code>XFD1048576</code>. That is about 17.2 billion cells in theory, far more than memory allows in practice. The older XLS format allowed 65,536 rows and 256 columns. A cell holds up to 32,767 characters, a sheet name up to 31 characters, and a number keeps 15 digits. Row and column limits matter when you export a large query: rows past the limit are lost or the write fails (<a href=\"/library/excel-row-column-limits\">Excel row and column limits and how to work around them</a>). If your table is bigger than a million rows, Excel is the wrong container; use CSV or a database, or a columnar format such as Parquet.</p>\n<p>The limits that surprise people are the practical ones. A workbook with a few hundred thousand rows and many formulas opens slowly and may run out of memory on 32-bit builds. A file with a million rows takes tens of seconds to parse in most libraries. Those figures depend on the hardware and library, and the right response is to measure with your own file.</p>\n<h2>Excel versus CSV, in one comparison</h2>\n<p>CSV is plain text with no types, no styles and no formulas. Excel is a rich container with all of those. For moving data between systems, CSV's poverty is an advantage: what you write is what the other side reads, and no cell can hide a formula or a merged region. Excel wins when the audience is a person who will filter, format and annotate. The decision, with the failure modes of each side, is in <a href=\"/library/excel-csv-sharing-data-other\">Excel versus CSV for sharing data</a>. A short version: send CSV to machines and XLSX to people, and when a machine must read an XLSX, ask for a table on one sheet.</p>\n<h2>Reading a workbook in code</h2>\n<p>Every Python library on this site reads the same package, with different defaults:</p>\n<ul><li><p><strong>openpyxl</strong> is the reference reader and writer for XLSX. It gives you cell-level access to values, formulas, styles, merged ranges and tables (<a href=\"/library/reading-writing-excel-files-openpyxl\">reading and writing Excel files with openpyxl</a>).</p></li><li><p><strong>pandas</strong> wraps a reader engine and gives you DataFrames in one call, which is the shortest route for tabular sheets (<a href=\"/library/working-excel-pandas\">working with Excel in pandas</a>).</p></li><li><p><strong>DuckDB</strong> reads XLSX through its Excel extension, which is useful for querying a sheet with SQL without a Python step (<a href=\"/library/loading-excel-files-duckdb\">loading Excel files with DuckDB</a>).</p></li><li><p><strong>Power Query</strong> inside Excel reads other files into Excel with a repeatable recipe (<a href=\"/library/power-query-repeatable-excel-cleanups\">Power Query for repeatable Excel cleanups</a>).</p></li></ul>\n<p>For loading into a database, the order of operations is nearly always the same: read the sheet as text where you can, stage it in a table with permissive types, check counts, then convert into the final table (<a href=\"/library/staging-excel-data-table\">staging Excel data in a table</a>, <a href=\"/library/validating-row-counts-after-excel\">validating row counts after an Excel import</a>).</p>\n<h2>How a reader turns a cell into a Python value</h2>\n<p>A library that reads a sheet applies the same small decision list to every cell, and knowing the list explains most surprises. If the cell has <code>t=\"s\"</code>, look up the string. If it has <code>t=\"b\"</code>, return a boolean. If it has <code>t=\"e\"</code>, return an error marker, which pandas converts to NaN and openpyxl returns as the error text such as <code>#N/A</code>. If it has no type, it is a number, and now the library checks the style: when the number format is a date or time format, it converts the serial number into a date or datetime, and otherwise it keeps a number. A formula cell goes through the same steps using its cached value, unless the library was asked for formulas.</p>\n<p>Two outcomes follow. The first is that the date decision depends on the format string, and custom formats that do not look like dates to the library leave the serial number as it is. A column that shows <code>Jan-24</code> is read as a date; a column formatted with a vendor-specific pattern may not be. The second is that a column's final type is chosen after the cells are read. pandas inspects the values of a whole column and picks one type: integers with a missing value become floats, a column mixing numbers and text becomes <code>object</code>, and a column whose cells are all dates becomes a datetime column. Mixed columns are the frequent cause of \"it worked for last month's file\" failures, and <a href=\"/library/detecting-mixed-types-excel-column\">detecting mixed types in an Excel column before import</a> shows how to find them first.</p>\n<p>The remedy is the same in every case: decide the type yourself. Read identifiers as text, name the date columns you expect, and convert at a point where a failure is loud. <a href=\"/library/specifying-column-types-read-excel\">Specifying column types in read_excel</a> shows the syntax, and the staging approach in the database articles moves the same decision into SQL.</p>\n<h2>A first look at an unfamiliar workbook</h2>\n<p>When someone sends a workbook and asks you to load it, spend five minutes before writing code:</p>\n<ol><li><p>List the parts with the script above. Look for <code>media</code>, <code>vbaProject.bin</code> (macros), <code>externalLinks</code> and many sheets.</p></li><li><p>Open <code>xl/workbook.xml</code> and check the sheet names, any <code>state=\"hidden\"</code> attributes, and <code>date1904</code>.</p></li><li><p>Open the first sheet and look at the header row and the first few data rows, as the program will see them.</p></li><li><p>Search the sheet XML for <code>&lt;f&gt;</code>: count the formulas and decide whether you need values or recipes.</p></li><li><p>Search for <code>mergeCell</code> to see whether merged regions exist.</p></li><li><p>For any date-looking column, look at the cell's style and confirm it is a date format, not text.</p></li><li><p>Compare the row count of the XML with the count the sender expects.</p></li></ol>\n<p>Doing this once teaches you more about a file than ten trial imports. After a while you do it from memory, and the odd file stops being a surprise.</p>\n<table><thead><tr><th>Part</th><th>Where it lives</th><th>What it means for data</th></tr></thead><tbody><tr><td>Cell values</td><td><code>xl/worksheets/sheetN.xml</code></td><td>Numbers, shared string indexes, booleans, errors, formulas</td></tr><tr><td>Text</td><td><code>xl/sharedStrings.xml</code></td><td>Each distinct string stored once</td></tr><tr><td>Number formats</td><td><code>xl/styles.xml</code></td><td>Whether a number displays as a date, percentage or currency</td></tr><tr><td>Sheet list</td><td><code>xl/workbook.xml</code></td><td>Names, visibility, date system</td></tr><tr><td>Tables</td><td><code>xl/tables/tableN.xml</code></td><td>Named range with headers and extent</td></tr><tr><td>Merged ranges</td><td><code>mergeCells</code> in the sheet</td><td>Only the top-left cell has a value</td></tr></tbody></table>\n<h2>When you do not need to know what is inside the file</h2>\n<p><strong>You exchange simple, well-formed data.</strong> If both sides agree on one sheet, a header row, text for identifiers and ISO dates, a library's defaults work and the internals never matter. The guide pays off when the defaults fail.</p>\n<p><strong>You can ask for CSV.</strong> If the source system can export CSV, take it. You trade styles and formulas for a format you can read with <code>head</code>, and the trade is usually in your favor (<a href=\"/library/saving-excel-sheet-csv\">saving an Excel sheet as CSV</a>).</p>\n<p><strong>You use a managed import.</strong> Some tools, such as a database's import wizard or a BI product's connector, read the package for you. When they misread a column, you will be back here, but while they work, there is nothing to learn.</p>\n<p><strong>The file is a report, not data.</strong> A formatted summary with subtotals, charts and notes is meant to be read by a person. Request the source data instead of parsing the report.</p>\n<p>The format is specified in <a href=\"https://ecma-international.org/publications-and-standards/standards/ecma-376/\" target=\"_blank\" rel=\"noopener noreferrer\">ECMA-376, Office Open XML</a>, and Microsoft documents the product's constraints in <a href=\"https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3\" target=\"_blank\" rel=\"noopener noreferrer\">Excel specifications and limits</a>. To move a small sheet's data into JSON for inspection, try the <a href=\"/tools/csv-json\">CSV to JSON converter</a>.</p>","date_published":"2026-11-04T15:13:00+00:00","tags":["Excel"]},{"id":"https://datahacks.dev/library/merging-two-yaml-files-python","url":"https://datahacks.dev/library/merging-two-yaml-files-python","title":"Merging Two YAML Files in Python With a Deep Merge","summary":"dict update is shallow and replaces whole blocks. A 10-line deep merge, the list policy choice and what null means, tested on a base and an override file.","content_html":"<p>You have a base configuration and an override: defaults plus an environment's changes, or a shared file plus a local one. Combining them looks like <code>{**base, **override}</code>. It is wrong in the way that matters most: it replaces whole blocks. This post shows the shallow merge fail, writes the recursive merge in ten lines, and covers the decisions a merge function must make about lists and nulls, because the answers are policy, not code.</p>\n<h2>The two files</h2>\n<pre><code class=\"language-yaml\"># base.yaml\nserver: {host: db1, port: 5432, opts: {ssl: true, pool: 5}}\nfeatures: [search, export]\ndebug: false</code></pre>\n<pre><code class=\"language-yaml\"># override.yaml\nserver: {host: db2, opts: {pool: 20}}\nfeatures: [billing]\ndebug: ~</code></pre>\n<p>The override changes the host and the pool size and leaves the port and <code>ssl</code> alone (by not mentioning them). It also replaces the features list and sets <code>debug</code> to null. The intended result keeps the port and TLS setting.</p>\n<h2>The shallow merge</h2>\n<pre><code class=\"language-python\">{**base, **override}</code></pre>\n<pre><code class=\"language-text\">shallow update: {'server': {'host': 'db2', 'opts': {'pool': 20}}, 'features': ['billing'], 'debug': None}</code></pre>\n<p>The <code>server</code> key from the override replaced the base's <code>server</code> completely. The port 5432 and <code>ssl: true</code> are gone, though the override never mentioned them. <code>dict.update</code> and the <code>**</code> merge operate on the top level only. For flat configuration it is fine. For nested configuration it silently drops settings, which is how a service ends up running on a default port in production.</p>\n<h2>The deep merge</h2>\n<p>A recursive function merges mappings key by key and replaces everything else:</p>\n<pre data-filename=\"merge.py\"><code class=\"language-python\">import copy\n\ndef merge(x, y, lists=\"replace\"):\n    out = copy.deepcopy(x)\n    for k, v in y.items():\n        if isinstance(v, dict) and isinstance(out.get(k), dict):\n            out[k] = merge(out[k], v, lists)\n        elif isinstance(v, list) and isinstance(out.get(k), list) and lists == \"append\":\n            out[k] = out[k] + v\n        else:\n            out[k] = copy.deepcopy(v)\n    return out</code></pre>\n<pre><code class=\"language-text\">replace lists: {'server': {'host': 'db2', 'port': 5432, 'opts': {'ssl': True, 'pool': 20}}, 'features': ['billing'], 'debug': None}\nappend lists : {'server': {'host': 'db2', 'port': 5432, 'opts': {'ssl': True, 'pool': 20}}, 'features': ['search', 'export', 'billing'], 'debug': None}</code></pre>\n<p>Now <code>server</code> has the override's <code>host</code> (<code>db2</code>), the base's <code>port</code> (5432), and an <code>opts</code> mapping that combines both: <code>ssl</code> from the base, <code>pool</code> 20 from the override. The function recurses where both sides are mappings and takes the override's value otherwise. The <code>deepcopy</code> calls matter: without them the result shares nested dictionaries with the inputs, and a later change to the merged config would change the base too. This is the same sharing behavior that makes YAML aliases surprising (<a href=\"/library/yaml-anchors-aliases-merge-keys\">YAML anchors, aliases and merge keys</a>).</p>\n<h2>Decisions the code cannot make for you</h2>\n<p><strong>Lists: replace or append.</strong> The two outputs above differ on <code>features</code>. Replace says the override's list is the whole list. Append says it adds to the base's. Both are legitimate and documented in different tools: configuration layering usually replaces, because \"I want exactly these features\" is the common intent, while lists of things to run or paths to search sometimes append. A third policy, merging lists of mappings by a key (<code>name</code>), is what Kubernetes strategic merge does and needs a key per list. Choose one, write it in the documentation, and put it in the function name or an argument. Do not mix policies silently.</p>\n<p><strong>Null in the override.</strong> <code>debug: ~</code> overwrote the base's <code>false</code> with <code>None</code>. That is correct if null means explicitly unset. It is a surprise if the author wrote a key with no value as a placeholder and expected \"no change\" (<a href=\"/library/yaml-null-values-empty-string\">YAML null values and the empty string question</a>). A common rule: null removes the key, so you can unset a base setting. Another: null is ignored, so a placeholder is harmless. Pick one. Whichever it is, an empty value after a colon is easy to write by accident, so lint for it.</p>\n<p><strong>Type conflicts.</strong> If the base has a mapping and the override has a scalar for the same key, the override wins in the function above. Often that is a mistake in the override. Decide whether to raise.</p>\n<p><strong>Unknown keys.</strong> A key in the override that is absent from the base may be a typo. A validation step after the merge catches it (<a href=\"/library/validating-yaml-configuration-pydantic\">validating YAML configuration with pydantic</a>).</p>\n<p><strong>Order.</strong> Python dictionaries keep insertion order, so the merged dictionary has the base's keys first and then new keys from the override. If order matters in the output file, say so.</p>\n<h2>Merging with more than two files</h2>\n<p>Layer by folding: <code>functools.reduce(merge, [base, env, local])</code>, or a loop over files sorted by name (<code>10-base.yaml</code>, <code>20-prod.yaml</code>, <code>30-local.yaml</code>), each merged onto the result so far. The sorted-by-name directory pattern is easy to reason about and common in tools. In the example in this cluster, the effective config after each file made it clear which file set which value, and printing the intermediate results is a cheap debugging aid (<a href=\"/library/splitting-large-yaml-config-several\">splitting a large YAML config into several files</a>).</p>\n<p>Keep the origin of each value if you can. A configuration object that records which file set each leaf lets you answer \"why is the port 8080?\" without guessing. It costs a parallel structure, and it saves hours.</p>\n<h2>Preserving comments</h2>\n<p>If the merged result is written back to a file that people read, a plain dictionary merge loses comments. Do the merge on <code>ruamel.yaml</code> round-trip objects, which behave like dictionaries; the function works unchanged on them, and comments attached to the base keys survive (<a href=\"/library/preserving-comments-order-ruamel-yaml\">preserving comments and order with ruamel yaml</a>). Test that the merge keeps the type of container: the function above copies with <code>deepcopy</code>, and the result is the same class as the input.</p>\n<h2>The command-line equivalent</h2>\n<p><code>yq</code> has a deep-merge operator, <code>*</code>, and an array-appending variant, <code>*+</code>:</p>\n<pre><code class=\"language-bash\">yq eval-all 'select(fi==0) * select(fi==1)' base.yaml override.yaml</code></pre>\n<pre><code class=\"language-text\">server:\n  host: db2\n  port: 5432\n  opts: {ssl: true, pool: 20}\nfeatures: [billing]</code></pre>\n<p>and with <code>*+</code> the list became <code>[search, export, billing]</code>, the same two policies as the Python function. The command is useful in shell scripts and CI (<a href=\"/library/merging-yaml-files-command-line\">merging YAML files from the command line</a>).</p>\n<h2>Related reading</h2>\n<p>The cluster hub is <a href=\"/library/reading-writing-yaml-python-safely\">reading and writing YAML in Python safely</a>. Anchors and merge keys inside one file are in <a href=\"/library/yaml-anchors-aliases-merge-keys\">YAML anchors, aliases and merge keys</a>, and environment layering in <a href=\"/library/environment-specific-yaml-config\">environment specific YAML config without copy and paste</a>. Python's copy semantics are in the <a href=\"https://docs.python.org/3/library/copy.html\" target=\"_blank\" rel=\"noopener noreferrer\">copy module documentation</a>, and Kubernetes' list-merge strategies in its <a href=\"https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/\" target=\"_blank\" rel=\"noopener noreferrer\">strategic merge patch docs</a>. Merge two snippets visually in the <a href=\"/tools/yaml-formatter\">YAML formatter</a>.</p>\n<h2>When a generic deep merge is the wrong tool</h2>\n<p><strong>Lists of records have identity.</strong> Merging two lists of services by position is wrong. Merge by a key such as <code>name</code>.</p>\n<p><strong>The structure is a schema, not data.</strong> For Kubernetes-style objects, use the platform's own patch or overlay tool, which knows the fields.</p>\n<p><strong>You need to remove things.</strong> A merge only adds and replaces. Removal needs an explicit convention, like null meaning delete.</p>\n<p><strong>The inputs are untrusted.</strong> A merge of attacker-controlled data into trusted defaults can set keys you did not intend. Validate the result against a schema with forbidden extras.</p>","date_published":"2026-11-04T00:32:00+00:00","tags":["YAML"]},{"id":"https://datahacks.dev/library/getting-mysql-server-running","url":"https://datahacks.dev/library/getting-mysql-server-running","title":"Getting a MySQL Server Running and Usable From Scratch","summary":"A beginner guide to getting a MySQL server running: choose a version, install, secure, connect, create users and fix the first errors.","content_html":"<p>The first hour with a new MySQL server decides a lot of what follows. A server installed carelessly, with a root account that anyone on the network can try, a data directory nobody can find and an application that connects as root, will cause trouble months later, and it will be harder to fix then. The same hour spent well gives a server that is secure by default, documented in a few lines and easy for the next person to understand. This guide walks through that hour: choosing a version, installing, securing, creating accounts, connecting, finding the files and meeting the common errors. It links to the detailed articles in the series for each step.</p>\n<h2>Decide what you are building</h2>\n<p>Before installing anything, answer four questions. Is this a development machine, a shared test server or production? A development database can live in a container and be thrown away, while production needs backups, monitoring and access control from the first day. Who needs to connect, and from where? A single application on the same host is a very different case from several services and analysts across a network. How much data and traffic do you expect? A few gigabytes with light traffic runs on a small virtual machine, and hundreds of gigabytes needs thought about memory and storage. And who will run it? If nobody on the team wants to be responsible for patching, backups and recovery, a managed cloud service may be the better start, with the trade-offs in <a href=\"/library/running-postgresql-managed-services\">running PostgreSQL on managed services</a>, which applies in spirit to MySQL services as well. Write the answers in a sentence or two; they will guide the choices below.</p>\n<h2>Choose the version</h2>\n<p>For a new project, install MySQL 8.4, the current long term support release. It receives fixes for years without feature changes, which is what a database should do. Innovation releases such as 9.x offer new features sooner and need upgrades every few months, a choice for teams with a testing pipeline. MySQL 8.0 is at or near the end of its supported life and should not host new work. Managed services decide which versions they offer, so check the provider's list and its retirement dates before choosing. MariaDB is a separate product with differences that this series does not assume. The reasoning and the support calendar are in <a href=\"/library/mysql-version-choose-new\">which MySQL version to choose for a new project</a>.</p>\n<h2>Install it</h2>\n<p>There are two good routes. A native package installs the server as a service on the host, managed by the operating system. On macOS use Homebrew or the Oracle installer, on Ubuntu and Debian the MySQL APT repository, on Red Hat family systems the MySQL Yum repository, on Windows the MSI installer. Each puts the data in a conventional directory and registers the service, and the platform differences are covered in <a href=\"/library/installing-mysql-8-4-macos\">installing MySQL 8.4 on macOS, Linux and Windows</a>. The second route is a container: the official <code>mysql:8.4</code> image with a named volume for the data directory runs identically on every developer's machine and disappears cleanly when you are done, as shown in <a href=\"/library/running-mysql-docker-data-volume\">running MySQL in Docker with a data volume</a>. Containers shine for development and testing. For a server that lives for years, a native install or a managed service is simpler to operate, unless your organization already runs containers well.</p>\n<h2>Secure it on day one</h2>\n<p>A fresh server needs five minutes of hardening, and it is much easier now than after an application depends on it. Set a long, generated password for the root account, using <code>ALTER USER 'root'@'localhost' IDENTIFIED BY '...'</code>. Run <code>mysql_secure_installation</code>, which removes anonymous accounts and the test database and disallows remote root login, covered in <a href=\"/library/mysql-removing-anonymous-users-test-databases\">removing anonymous users and test databases</a>. Keep the server from listening on the public internet: bind to localhost or a private address, and put a firewall in front. If remote clients must connect, require TLS and restrict the accounts by host. Keep the server patched with minor updates. These steps are cheap, and skipping them is how databases end up in breach reports.</p>\n<h2>Create the database and the accounts</h2>\n<p>Create the database with <code>utf8mb4</code> and a clear name. Then create separate accounts for separate jobs: one for the application with <code>SELECT, INSERT, UPDATE, DELETE</code> on its database, one for schema migrations with the data definition privileges, one read-only for reporting, and a personal account for each administrator. Each account is a user and a host pattern, and the host pattern should be as narrow as the network allows. Never let an application use root. The statements, the role feature in MySQL 8 and the verification steps are in <a href=\"/library/creating-database-least-privilege-user\">creating a database and a least privilege user in MySQL</a>. Keep the statements in version control so that every environment is created the same way, and use generated passwords stored in a secret manager.</p>\n<h2>Connect with the command line client</h2>\n<p>The <code>mysql</code> client is the baseline tool. It connects through a Unix socket when the host is <code>localhost</code> and through TCP for any other name, and the two paths match different accounts and fail in different ways. Put repeated settings in an option file or a login path, keep passwords off the command line, request TLS with <code>--ssl-mode</code> when crossing a network and use an SSH tunnel for private servers. The options and the habits are in <a href=\"/library/connecting-mysql-command-line\">connecting to MySQL from the command line</a>. Most \"cannot connect\" problems resolve by reading the error number: 2002 is a socket problem, 2003 a network problem, 1045 a credentials problem.</p>\n<h2>Make the client safer</h2>\n<p>Before you spend hours in a production console, configure the client. <code>--safe-updates</code> makes the server refuse <code>UPDATE</code> and <code>DELETE</code> statements without a key-based <code>WHERE</code>, and caps accidental huge selects. A pager keeps long results readable, <code>\\G</code> prints wide rows vertically, and a prompt that shows the host and database tells you where you are before you press Enter. Put them in the <code>[mysql]</code> section of your option file, as described in <a href=\"/library/mysql-client-options-like-safe\">using mysql client options like safe updates and pager</a>. Wrap manual data changes in transactions and check the row count before committing. These habits cost nothing and prevent the incidents that are remembered for years.</p>\n<h2>Know where things live</h2>\n<p>Most mysteries about a MySQL server are answered by knowing where its files are. The server tells you: <code>SELECT @@datadir, @@socket, @@log_error</code>. The configuration comes from layered option files, read in a fixed order with later settings winning, plus the <code>mysqld-auto.cnf</code> file written by <code>SET PERSIST</code>. The data directory holds the InnoDB tablespaces, redo and undo logs, one folder per database and the binary logs if enabled. Defaults differ between Debian, Red Hat, macOS, Windows and Docker. Never copy or delete files in the data directory while the server runs. The layout and the order of option files are explained in <a href=\"/library/where-mysql-keeps-config\">where MySQL keeps its config and data files</a>.</p>\n<h2>The three first errors</h2>\n<p>Three errors bring most newcomers to a search engine. <code>ERROR 1045 Access denied for user 'root'@'localhost'</code> means the server was reached and refused the credentials: check the password, whether root uses socket authentication, which host the server saw and whether the client supports the authentication plugin. The checklist is in <a href=\"/library/mysql-fixing-access-denied-user-root\">fixing access denied for user root at localhost</a>. <code>ERROR 2002 Can't connect to local MySQL server through socket</code> means the client looked for a socket file that is missing or unreachable: check that the server runs, find the real socket path and use TCP with <code>127.0.0.1</code> if needed, see <a href=\"/library/fixing-not-connect-local-mysql\">fixing can not connect to local MySQL server through socket</a>. And the lost root password, which you reset with an init file as described in <a href=\"/library/resetting-lost-mysql-root-password\">resetting a lost MySQL root password</a>. Know these three and you will recover from the majority of first-day problems.</p>\n<h2>Character sets, time zones and modes</h2>\n<p>A few server settings deserve a decision early, because changing them later is painful. Use <code>utf8mb4</code> as the server and database character set, and a collation that matches your comparison needs. Decide how time is stored: set the server time zone deliberately (UTC is the safest default for servers) and choose between <code>DATETIME</code> and <code>TIMESTAMP</code> with the trade-offs in <a href=\"/library/datetime-timestamp-mysql\">DATETIME versus TIMESTAMP in MySQL</a>. Keep the default strict <code>sql_mode</code>, which makes the server reject invalid data instead of silently truncating it; relaxing it to satisfy a sloppy application only hides bugs. These settings go in the configuration file, under version control, with a comment on why each is set.</p>\n<h2>Think about backups from the start</h2>\n<p>A server without a tested backup is a risk that grows with every row. Decide the method early: logical dumps with <code>mysqldump</code> or the MySQL Shell utilities for small and medium databases, physical backups with Percona XtraBackup for large ones, binary logs for point-in-time recovery. Schedule them, send them off the machine, and restore one into a scratch server to prove that it works. The details are in the backup series, starting with <a href=\"/library/mysql-backups-count\">MySQL backups you can count on</a>. Even on a development machine, a script that dumps the schema and seed data into the repository saves hours when the volume is lost.</p>\n<h2>Monitor the basics</h2>\n<p>Even a small server benefits from a few checks. Watch that it is up and accepting connections, that disk space is not running out, that the error log does not fill with warnings, that the number of connections stays below <code>max_connections</code> and that backups ran. Later you will add query performance, replication lag and buffer pool statistics, covered in the tuning and performance series. A cron job that mails on failure is better than nothing, and a metrics stack is better still. Enable the slow query log with a sensible threshold early, see <a href=\"/library/mysql-turning-slow-query-log-safely\">turning on the slow query log safely</a>, because it costs little and gives you evidence when someone says the database is slow.</p>\n<h2>A first-hour checklist</h2>\n<p>When you set up a server, run through a short list. Version chosen and recorded. Installed from a supported source. Root password changed and anonymous accounts gone. The server bound to the right interface, with a firewall. Database created with <code>utf8mb4</code>. Application, migration and reporting accounts created with narrow grants and host patterns. TLS configured for remote connections. Client defaults set for safety. Paths of the configuration, data and logs written down. Time zone and <code>sql_mode</code> set deliberately. Backup job running, and one restore tested. Slow query log on. Monitoring and an alert for disk space and for the service being down. A runbook page with the commands for the three first errors. Keep the checklist in the repository and update it after each setup, and the next server takes ten minutes instead of an afternoon.</p>\n<h2>Network exposure and firewalls</h2>\n<p>The most common serious mistake with a new MySQL server is leaving it reachable from places it should not be. The server listens on port 3306, and <code>bind_address</code> decides which interfaces accept connections: <code>127.0.0.1</code> means only the local machine, <code>*</code> means all. For a single-host setup, keep it on loopback. For a server shared with other machines, bind to the private address and open the firewall for the specific clients only. Cloud security groups, <code>ufw</code>, <code>firewalld</code> and the Windows firewall all do this, and a rule that allows <code>0.0.0.0/0</code> on 3306 is the line to look for in any review. Docker's published ports bypass some host firewalls, so publish to <code>127.0.0.1</code> explicitly. If people need access from laptops, give them an SSH tunnel or a VPN, not an open port. Scanners find open MySQL ports within hours, and a weak password on one is enough.</p>\n<h2>First settings worth changing</h2>\n<p>The default configuration is conservative. A few settings are worth setting at the start. Set <code>innodb_buffer_pool_size</code> to a value that fits your memory, about half to three quarters of RAM on a dedicated database host, less when the machine runs other services; the reasoning is in <a href=\"/library/mysql-big-should-innodb-buffer-pool\">how big should innodb_buffer_pool_size be</a>. Set <code>max_connections</code> to a number you can afford in memory, not an arbitrary large value. Turn on the slow query log. Set <code>character_set_server</code> to <code>utf8mb4</code>. Set the time zone. Enable binary logging if you will need point-in-time recovery or replication, and set an expiry for old binary logs, otherwise they fill the disk. Leave the rest alone until you have measurements, and write each change with its reason as a comment in the configuration file.</p>\n<h2>Time zones and clocks</h2>\n<p>Time handling trips up many beginners. MySQL stores <code>TIMESTAMP</code> values in UTC and converts them to the session time zone on the way in and out, while <code>DATETIME</code> stores what you give it with no conversion. The server has a global time zone setting, and each session can override it, so the same stored value can display differently to two clients. Set the server to UTC, load the time zone tables if you want named zones (<code>mysql_tzinfo_to_sql</code> on Linux), and have applications set the session zone explicitly. Keep the operating system clock synchronized with NTP, since replication, certificates and log analysis depend on it. The choice of column type has consequences for ranges and conversions, covered in <a href=\"/library/datetime-timestamp-mysql\">DATETIME versus TIMESTAMP in MySQL</a>.</p>\n<h2>Patching and upgrades</h2>\n<p>Treat updates as routine work. Minor releases, such as 8.4.2 to 8.4.3, contain security and bug fixes and are safe to apply with a restart. Subscribe to Oracle's security advisories, which come quarterly, and apply them within a defined time. Package managers do most of the work, and containers need a new image tag. Test the update on a copy first if the server is important, and take a backup before. Major changes, such as the move to the next LTS release, need more care: read the upgrade notes, test the application, plan the downtime and keep a rollback path. Replicas are upgraded before the source. A calendar entry for the end-of-support date of your version prevents the surprise of running unpatched software. The version policy is explained in <a href=\"/library/mysql-version-choose-new\">which MySQL version to choose for a new project</a>.</p>\n<h2>Logging that helps</h2>\n<p>Logs are how you learn what a server did. The error log is on by default and is the first place to look for any startup problem or crash. The slow query log, off by default, records statements slower than <code>long_query_time</code>; a threshold of one second or less with <code>log_slow_extra</code> gives useful detail. The general log records every statement and is only for short debugging, since it is large and can contain passwords. The binary log records changes for recovery and replication. Rotate logs with <code>logrotate</code> or the server's own options, watch their size, and protect them, as they can contain sensitive data. Where logs go in containers, on managed services and under systemd differs, so find out early.</p>\n<h2>Development versus production</h2>\n<p>A development database and a production database should be as similar as possible in version, character set, SQL mode and schema, and as different as possible in size, access and data. Developers need a quick way to create a fresh copy: a container, a dump of the schema and generated sample data, not a copy of production data, which brings privacy risk. Production needs access control, backups, monitoring and a change process. Keep the configuration files in the same repository and differ only in a small set of values. A common failure is the application that works on a laptop with permissive settings and fails in production with strict ones; run the test suite against a server with the production <code>sql_mode</code> and character set.</p>\n<h2>Frameworks and drivers</h2>\n<p>Applications reach MySQL through drivers and frameworks that bring their own defaults. Check the connection string options: the character set, the time zone, whether TLS is requested, the connection pool size and the timeouts. Check that the driver supports the authentication plugin your server uses; recent versions of the mainstream drivers support <code>caching_sha2_password</code>. Set the pool size from the server's connection budget and not from the framework's default, and set a connection timeout so that an unavailable server fails fast. Prepared statements and parameter binding in the driver, not string concatenation, are the first defense against injection, covered in <a href=\"/library/preventing-sql-injection-mysql-applications\">preventing SQL injection in MySQL applications</a>. Test failover behavior if you run replicas: does the application reconnect after the server restarts?</p>\n<h2>A troubleshooting flow</h2>\n<p>When something does not work, move from the outside in. Is the host reachable (<code>ping</code>, DNS)? Is the port open (<code>nc -vz host 3306</code>)? Is the server running (<code>systemctl status</code>, <code>mysqladmin ping</code>)? Does the error log show a problem? Can you connect locally over the socket? Over TCP? With which account? What do <code>SHOW GRANTS</code> and <code>mysql.user</code> say? Is the plugin compatible? Is TLS required and configured? Each question narrows the layer, and the error number says where to begin: 2002 and 2003 are connectivity, 1045 is authentication, 1044 and 1142 are privileges, 1049 is an unknown database. Write the flow in your runbook with the commands for your environment. A calm checklist beats a panicked guess at 3 a.m.</p>\n<h2>Ownership and documentation</h2>\n<p>A database server needs an owner: a named person or team who decides on upgrades, grants access, watches alerts and answers questions. Write down in one page where the server lives, how it was installed, which version it runs, where the configuration is, who has access, how backups work and where, how to restore and how to reach the owner. Review the page twice a year. The most useful documentation is short and exact: commands that were tested, not descriptions of intentions. When someone leaves the team, the page and the secret manager are what keep the server operable. Treat undocumented servers as a risk to be fixed, whatever their age.</p>\n<h2>Storage and hardware basics</h2>\n<p>MySQL is more sensitive to disk than to almost anything else. Use SSD or NVMe storage for the data directory, and keep the redo log and binary logs on storage with predictable latency. A busy server on a slow disk spends its time waiting, and no setting repairs that. Leave headroom: a data volume that is 90 percent full leaves no space for index builds, temporary tables or <code>ALTER TABLE</code> operations that copy a table. Memory comes next, since a buffer pool that holds the working set turns most reads into memory hits. CPU matters for concurrent queries, and a few fast cores serve many small workloads better than many slow ones. In virtual machines and clouds, check the disk's IOPS and throughput limits, because credits and burst allowances can run out under sustained load. Plan growth: measure the size of the data directory monthly, and extend storage well before it fills.</p>\n<h2>A practical day-two list</h2>\n<p>After the first hour, the work continues for a few days. Add a monitoring agent or a metrics exporter, and dashboards for connections, queries per second, the buffer pool and replication. Set alerts for disk space, service availability and failed backups. Write a runbook entry for how to restore. Run a restore into a scratch server and time it. Review the user list and the grants. Check the slow query log after a day of real traffic and fix the worst query. Look at the error log for warnings that appear every minute and silence them by fixing the cause. Put the configuration in version control. Schedule the next review in a month. Small servers that receive this attention in the first week tend to stay healthy for years, and those that do not tend to be rescued in a hurry after an incident.</p>\n<h2>When a managed service is the better start</h2>\n<p>If your team lacks the time or the inclination to run a database, a managed MySQL service removes the installation, patching, backups and failover from your list and adds a bill. You still do the account design, the schema, the queries and the monitoring, and you lose some control: no host access, a restricted set of settings and privileges, and a provider-set upgrade schedule. For a small team building a product, that trade is usually right. For a team with operations experience and specific needs, a self-run server is fine. The decision is reversible with a dump and restore, so it need not be agonized over; pick one, document it and review in a year.</p>\n<h2>Where to go from here</h2>\n<p>The articles in this series cover each step: <a href=\"/library/installing-mysql-8-4-macos\">installing MySQL 8.4 on macOS, Linux and Windows</a>, <a href=\"/library/running-mysql-docker-data-volume\">running MySQL in Docker with a data volume</a>, <a href=\"/library/creating-database-least-privilege-user\">creating a database and a least privilege user in MySQL</a>, <a href=\"/library/connecting-mysql-command-line\">connecting to MySQL from the command line</a>, <a href=\"/library/mysql-fixing-access-denied-user-root\">fixing access denied for user root at localhost</a>, <a href=\"/library/fixing-not-connect-local-mysql\">fixing can not connect to local MySQL server through socket</a>, <a href=\"/library/resetting-lost-mysql-root-password\">resetting a lost MySQL root password</a>, <a href=\"/library/where-mysql-keeps-config\">where MySQL keeps its config and data files</a>, <a href=\"/library/mysql-version-choose-new\">which MySQL version to choose for a new project</a> and <a href=\"/library/mysql-client-options-like-safe\">using mysql client options like safe updates and pager</a>. The authority is the <a href=\"https://dev.mysql.com/doc/refman/8.4/en/\" target=\"_blank\" rel=\"noopener noreferrer\">MySQL 8.4 reference manual</a> and the <a href=\"https://dev.mysql.com/doc/refman/8.4/en/installing.html\" target=\"_blank\" rel=\"noopener noreferrer\">installing and upgrading chapter</a>, and the next topics are data types in <a href=\"/library/picking-mysql-data-types-care\">picking MySQL data types with care</a> and query writing in <a href=\"/library/writing-mysql-8-queries-modern\">writing MySQL 8 queries that use modern features</a>.</p>","date_published":"2026-11-03T15:33:00+00:00","tags":["MySQL"]},{"id":"https://datahacks.dev/library/json-allows-parsers-quietly-accept","url":"https://datahacks.dev/library/json-allows-parsers-quietly-accept","title":"What JSON Allows and What Parsers Quietly Accept in Practice","summary":"The JSON grammar fits on a page, and parsers still disagree on trailing commas, NaN, duplicate keys, big numbers and more. A 26-case test across five parsers.","content_html":"<p>JSON is the one data format that every developer thinks they understand. It has six kinds of value, a grammar that fits on the back of a business card and a name that suggests it is just JavaScript. Then a config file with a trailing comma works in one tool and fails in another, a service returns an ID that is off by three in the last digit, and two systems read the same document and disagree about what it says. The format is simple. The edge cases, the places where the specification says \"may\" or says nothing, are where parsers go their own ways, and most real JSON bugs live there. This guide lays out what the grammar allows, then shows what five common parsers do with the inputs at its edges, based on a 26-case experiment, and ends with how to write and read JSON so that those differences do not matter. The tooling around the format, including the command-line side, is surveyed in <a href=\"/library/fixing-json-parse-errors-reading\">JSON parse errors and how to read them</a>, and the detailed cheat sheet of test inputs is in <a href=\"/library/cheat-sheet-json-edge-cases\">a cheat sheet of JSON edge cases to throw at a parser</a>.</p>\n<h2>What the grammar allows</h2>\n<p>JSON is defined by RFC 8259 and, in nearly identical terms, by ECMA-404. A value is one of six things: an object, an array, a string, a number, <code>true</code> or <code>false</code>, or <code>null</code>. That is the whole type system; there is no date, no binary, no integer as distinct from a float, no comment and no way to say \"undefined\".</p>\n<p>An object is braces around zero or more <code>\"name\": value</code> pairs separated by commas, and the name must be a string in double quotes. An array is brackets around zero or more values separated by commas. A string is double quotes around characters, with the escapes <code>\\\"</code>, <code>\\\\</code>, <code>\\/</code>, <code>\\b</code>, <code>\\f</code>, <code>\\n</code>, <code>\\r</code>, <code>\\t</code> and <code>\\uXXXX</code>, and with raw control characters (below U+0020) forbidden: a literal tab or newline inside a string is not allowed. A number is an optional minus, an integer part with no leading zeros (<code>0</code> alone, or a digit 1 to 9 followed by digits), an optional fraction that needs digits on both sides of the point, and an optional exponent. Whitespace, meaning space, tab, newline and carriage return, may appear between tokens.</p>\n<p>Since RFC 7159, a document can be any value, so <code>42</code> and <code>\"hello\"</code> are valid JSON texts on their own, not only objects and arrays. The older RFC 4627 allowed only the two containers at the top. Nothing in the grammar allows trailing commas, comments, single-quoted strings, unquoted names, <code>NaN</code>, <code>Infinity</code>, hexadecimal, a leading plus sign, a number like <code>.5</code> or <code>1.</code> or a second document after the first. A parser that accepts any of those is accepting more than the format, and that is where the tour begins.</p>\n<p>The RFC is also explicit that parsers have latitude. It says that an implementation may set limits on the size of texts, the depth of nesting, the range and precision of numbers and the length of strings, and that a parser may accept non-JSON forms. So \"not valid JSON\" is not a property of the bytes alone: it is a statement about a parser, and the next section shows how much parsers vary.</p>\n<h2>The experiment</h2>\n<p>I wrote 26 inputs, each a small file with exact bytes, and asked five parsers whether they accept it. The parsers were Python's <code>json.loads</code> on text, Node's <code>JSON.parse</code>, PHP's <code>json_decode</code>, jq 1.6 and DuckDB's <code>json_valid</code>. Here are the results for the inputs that matter most, with <code>ok</code> for accepted and <code>ERR</code> for rejected:</p>\n<table><thead><tr><th>Input</th><th>Python</th><th>Node</th><th>PHP</th><th>jq 1.6</th><th>DuckDB</th></tr></thead><tbody><tr><td><code>{\"a\": 1,}</code> trailing comma in object</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ok</td></tr><tr><td><code>[1, 2,]</code> trailing comma in array</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ok</td></tr><tr><td><code>{'a': 1}</code> single quotes</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ERR</td></tr><tr><td><code>{a: 1}</code> unquoted key</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ERR</td></tr><tr><td><code>// note</code> then an object</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ERR</td></tr><tr><td><code>[NaN]</code></td><td>ok</td><td>ERR</td><td>ERR</td><td>ok</td><td>ok</td></tr><tr><td><code>[Infinity]</code></td><td>ok</td><td>ERR</td><td>ERR</td><td>ok</td><td>ok</td></tr><tr><td><code>{\"a\": 1, \"a\": 2}</code> duplicate keys</td><td>ok</td><td>ok</td><td>ok</td><td>ok</td><td>ok</td></tr><tr><td><code>[01]</code> leading zero</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ok</td><td>ERR</td></tr><tr><td><code>[+1]</code> leading plus</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ok</td><td>ERR</td></tr><tr><td><code>[.5]</code> and <code>[1.]</code></td><td>ERR</td><td>ERR</td><td>ERR</td><td>ok</td><td>ERR</td></tr><tr><td><code>[\"a&lt;TAB&gt;b\"]</code> raw tab in a string</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ERR</td></tr><tr><td><code>[\"\\ud800\"]</code> lone surrogate escape</td><td>ok</td><td>ok</td><td>ERR</td><td>ERR</td><td>ERR</td></tr><tr><td>UTF-8 BOM before the object</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ok</td><td>ERR</td></tr><tr><td><code>42</code> top-level scalar</td><td>ok</td><td>ok</td><td>ok</td><td>ok</td><td>ok</td></tr><tr><td><code>{\"a\": 1}{\"b\": 2}</code> two documents</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ok</td><td>ERR</td></tr><tr><td>empty input</td><td>ERR</td><td>ERR</td><td>ERR</td><td>ok</td><td>ERR</td></tr><tr><td>5,000 levels of nesting</td><td>ok</td><td>ok</td><td>ERR</td><td>ERR</td><td>ok</td></tr></tbody></table>\n<p>The agreement is on the clear cases: single quotes, unquoted names, comments, raw control characters, hex numbers and bad escapes were rejected everywhere, and top-level scalars, huge exponents and big integers were accepted everywhere. Everything else splits the field.</p>\n<p>Some details need caveats. For Python, the BOM case depends on the input type: <code>json.loads</code> on a string beginning with the BOM character raises an error (\"Unexpected UTF-8 BOM\"), and on bytes it detects the BOM and accepts the document, which is why the same file can parse in a script that reads bytes and fail in one that reads text. jq accepted the two-documents input because it is a stream processor that reads values one after another, and accepted empty input because it printed nothing. DuckDB's <code>json_valid</code> is strict about numbers and quotes and lenient about commas, <code>NaN</code> and <code>Infinity</code>. PHP rejected the deeply nested input because <code>json_decode</code> defaults to a maximum depth of 512, which is a parameter and not a failure of the grammar.</p>\n<h2>What the values come out as</h2>\n<p>Acceptance is half of it. The values that come out differ too. For the same four inputs, Python, Node, PHP and jq produced:</p>\n<table><thead><tr><th>Input</th><th>Python</th><th>Node</th><th>PHP</th><th>jq 1.6</th></tr></thead><tbody><tr><td><code>{\"a\": 1, \"a\": 2}</code></td><td><code>{'a': 2}</code></td><td><code>{\"a\":2}</code></td><td><code>{\"a\":2}</code></td><td><code>{\"a\":2}</code></td></tr><tr><td><code>[1e400]</code></td><td><code>[inf]</code></td><td><code>[null]</code> after stringify</td><td>overflow, not encodable</td><td><code>[1.7976931348623157e+308]</code></td></tr><tr><td><code>[12345678901234567890123]</code></td><td>exact integer</td><td><code>1.2345678901234568e+22</code></td><td><code>1.2345678901234568e+22</code></td><td><code>12345678901234568000000</code></td></tr><tr><td><code>[9007199254740993]</code></td><td><code>9007199254740993</code></td><td><code>9007199254740992</code></td><td><code>9007199254740993</code></td><td><code>9007199254740992</code></td></tr></tbody></table>\n<p>Duplicate keys resolve to the last value in all four, a convention and not a rule: the RFC says names within an object \"SHOULD be unique\" and that behavior with duplicates is unpredictable. DuckDB's JSON type keeps the original text, duplicates included, until you extract a value. The overflowing exponent becomes infinity in Python, <code>null</code> after Node serializes it back and the largest double in jq. The 23-digit integer is exact in Python, which has arbitrary-precision integers, and a rounded float everywhere else.</p>\n<p>The last row is the one that bites. <code>9007199254740993</code> is 2 to the 53rd plus one, the first integer that a 64-bit float cannot represent. Node and jq read it as the float and print <code>9007199254740992</code>, off by one, and the reader has no way to know. PHP kept the exact 64-bit integer, and Python kept it as an arbitrary-precision one. If that number is an order ID, the same document identifies two different orders in two services (<a href=\"/library/json-handles-big-numbers\">how JSON handles big numbers and why IDs lose digits</a> has the fixes).</p>\n<h2>Why the differences exist</h2>\n<p>JSON was specified after it was in use. Douglas Crockford described it as the subset of JavaScript literals that was safe to exchange, and implementations grew from <code>eval</code> and from hand-written parsers that accepted what their authors thought was reasonable. Standards then wrote down what the strictest reading allowed, and left a lot to implementations. Every deviation in the table has a reason: Python allows <code>NaN</code> and <code>Infinity</code> because they are Python floats and <code>json.dumps</code> writes them by default; jq 1.6 was lenient about numbers because its parser accepts anything that looks like a numeric literal; DuckDB reads trailing commas because its JSON reader, built for analytics on messy files, is deliberately forgiving.</p>\n<p>The consequence is that a document can be valid for the system that wrote it and invalid for the one that reads it. A Python service that emits <code>NaN</code> for a missing measurement produces output that a JavaScript client cannot parse (<a href=\"/library/nan-infinity-valid-json\">is NaN or Infinity valid in JSON</a>). A config file with a trailing comma that a lenient tool accepts breaks the first strict tool in the pipeline (<a href=\"/library/trailing-commas-json\">trailing commas in JSON and why parsers reject them</a>).</p>\n<h2>Reading the table as a decision aid</h2>\n<p>The table is useful as a way to choose what to write. Take the rows where all five parsers agree on \"reject\": single quotes, unquoted names, comments, raw control characters, hexadecimal. Writing any of those is a guaranteed failure everywhere, and the fix is to stop. Take the rows where they split on rejection, trailing commas, <code>NaN</code>, leading zeros, a BOM and lone surrogates. Those are the risky ones: they work in your tests, because your parser tolerates them, and fail in production, because the consumer is stricter. The safe response is to treat the strictest parser's reading as the standard. Take the rows where all five accept but the values differ, duplicate keys and big integers: nothing fails and the data is quietly different. Those are the most dangerous, since no error ever tells you.</p>\n<p>That three-way sort, fails everywhere, fails somewhere, works everywhere with different meaning, is how I would triage any new JSON producer or consumer. Run the cases, put each in a bucket and fix the second and third first, because the first announces itself.</p>\n<h2>Strings, escapes and encodings</h2>\n<p>The RFC says that JSON exchanged between systems that are not part of a closed ecosystem must be encoded in UTF-8, and that implementations must not add a byte order mark, though they may ignore one when reading. That is the specification, and it explains the BOM row in the table: Python on bytes ignores it, and on text raises, Node and PHP reject it, jq and others skip it. A producer that writes a BOM is violating a MUST NOT, and a consumer that rejects it is within its rights, so the practical answer is to never write one and to strip one defensively when you read files that came from Windows editors.</p>\n<p>Escapes make the second source of trouble. <code>\\u00e9</code> and the raw character <code>é</code> are the same string. A character outside the Basic Multilingual Plane, such as the grinning face U+1F600, is written in an escaped form as a surrogate pair, <code>\\ud83d\\ude00</code>, two escapes standing for one character. Python's <code>json.dumps</code> writes that form by default (<code>ensure_ascii=True</code>) and the raw character with <code>ensure_ascii=False</code>; both parse back to the same string. The problem is a surrogate escape on its own, <code>\\ud800</code> with no partner. The grammar allows it, since it is a valid escape sequence, and it names no character. Python and Node accept it, PHP and jq reject it, and a library that then tries to encode the string as UTF-8 will fail or write a replacement character. Reject lone surrogates at the boundary (<a href=\"/library/unicode-escapes-surrogate-pairs-json\">unicode escapes and surrogate pairs in JSON</a>).</p>\n<p>String length shows the same split in another form. The grinning face is one character, four bytes in UTF-8 and two units in UTF-16. Python's <code>len</code> returned 1, jq's <code>length</code> returned 1, and JavaScript's <code>.length</code> returned 2, because JavaScript counts UTF-16 code units. A validation rule of \"at most 20 characters\" therefore means different things in a Python service and in a JavaScript form, and an emoji-heavy name fits in one and not the other. Count what the rule means and say which unit in the specification.</p>\n<h2>Numbers have no range in the grammar</h2>\n<p>The number grammar says nothing about size or precision. <code>1</code>, <code>1.0</code> and <code>1e0</code> are all legal, and a parser decides what to do with them. Python keeps the distinction: <code>json.loads(\"1.0\")</code> is a float and <code>json.loads(\"1\")</code> is an integer, and <code>json.dumps(-0.0)</code> writes <code>-0.0</code>. JavaScript has only one number type, so <code>JSON.parse(\"1.0\")</code> and <code>JSON.parse(\"1\")</code> are both <code>1</code>, and <code>JSON.stringify(-0)</code> writes <code>0</code>. A round trip through JavaScript therefore turns <code>1.0</code> into <code>1</code>, which is harmless unless a downstream system reads <code>1.0</code> as a decimal with a scale. The size problem was shown above: any integer above 2 to the 53rd is at risk in a parser that uses doubles.</p>\n<p>Treat numbers that must be exact, money, identifiers, counters beyond 9 quadrillion, as strings, or as integers in a documented range, and test the path through every parser that reads them.</p>\n<h2>A strict reader in a few lines</h2>\n<p>You can make Python's parser as strict as the specification with two hooks, one for the constants and one for object pairs, and a defensive BOM strip:</p>\n<pre><code class=\"language-python\">import json\n\ndef reject_constant(name):\n    raise ValueError(f\"non-standard number {name} is not allowed\")\n\ndef reject_duplicates(pairs):\n    seen = {}\n    for key, value in pairs:\n        if key in seen:\n            raise ValueError(f\"duplicate key {key!r}\")\n        seen[key] = value\n    return seen\n\ndef strict_loads(text):\n    if text.startswith(\"\\ufeff\"):\n        text = text[1:]\n    return json.loads(text, parse_constant=reject_constant, object_pairs_hook=reject_duplicates)</code></pre>\n<p>Run on five inputs:</p>\n<pre><code class=\"language-text\">'{\"a\": 1, \"b\": [true, null]}'        -&gt; {'a': 1, 'b': [True, None]}\n'[NaN]'                              -&gt; rejected: non-standard number NaN is not allowed\n'{\"a\": 1, \"a\": 2}'                   -&gt; rejected: duplicate key 'a'\n'{\"a\": 1,}'                          -&gt; rejected: Illegal trailing comma before end of object: line 1 column 8 (char 7)\n'\\ufeff{\"ok\": true}'                 -&gt; {'ok': True}</code></pre>\n<p>The valid document passes, <code>NaN</code> and the duplicate are rejected by the hooks, the trailing comma was already rejected by the parser (Python 3.13 words the error as \"Illegal trailing comma before end of object\"), and the BOM-prefixed document passes because the function strips it. About fifteen lines turn a lenient default into the strict contract. The equivalent hooks exist in other languages: a reviver or a custom parser in JavaScript, <code>JSON_THROW_ON_ERROR</code> and a depth argument in PHP, and a schema validator in front of everything.</p>\n<h2>Whitespace, key order and what \"the same document\" means</h2>\n<p>Two JSON texts can be different byte for byte and the same document. Whitespace between tokens is free, and the order of keys in an object has no meaning in the specification. In Python, <code>json.dumps({\"b\": 1, \"a\": [1, 2]})</code> gives <code>{\"b\": 1, \"a\": [1, 2]}</code>, and the compact form of the same data with sorted keys is <code>{\"a\":[1,2],\"b\":1}</code>: the two parse to equal values and compare unequal as text. That matters whenever you hash, sign or diff JSON. A hash of the text changes if a library changes its spacing or key order, so a signature needs a canonical form. RFC 8785, the JSON Canonicalization Scheme, defines one (sorted keys, no extra whitespace, a specified number format), and some libraries implement it. For diffs, sort the keys and indent consistently before comparing, or use a tool that compares parsed values.</p>\n<h2>A checklist for an API that sends and receives JSON</h2>\n<p>For what you send: UTF-8, no BOM, unique keys, finite numbers only, IDs and exact decimals as strings, timestamps as ISO 8601 strings with an offset (<a href=\"/library/json-dates-lack-date\">JSON dates and the lack of a date type</a>), a declared content type of <code>application/json</code> and nothing before or after the document. For what you receive: a size limit before parsing, a depth limit, a strict parser configuration as above, schema validation after parsing, an explicit decision about unknown fields (ignore or reject) and the same error shape for every kind of failure, so that clients can tell malformed JSON from valid JSON that violates the rules. Document both, with an example payload that a client can paste, and keep a conformance test with the awkward inputs next to the code that parses them.</p>\n<h2>How the leniency crept in</h2>\n<p>It helps to know why the format is both tiny and untidy. JSON started as a way to send data to a browser: the objects and arrays were already valid JavaScript, so a page could read a response with <code>eval</code>. Douglas Crockford named and described the subset, and the first spec, RFC 4627 in 2006, was a few pages. Early parsers were small and written by people who wanted them to work on the data they had, so they accepted a little more than the grammar. When languages other than JavaScript adopted the format, their authors made their own choices about what to do with <code>NaN</code>, with integers beyond the float range and with repeated names, and each choice became behavior that existing programs depended on. Later standards could tighten the text and could not change a million deployed parsers.</p>\n<p>The effect is that strictness is a property of a particular parser at a particular version, and almost never an accident of the format. A parser that is lenient today has users who rely on it, and a parser that is strict is easier to reason about and breaks files that used to work. Neither is wrong, and neither is safe to assume.</p>\n<h2>Large documents and streams</h2>\n<p>One more edge is size. The grammar allows a document of any length, and most parsers build the whole structure in memory, so a 2 GB array of records needs several gigabytes of RAM to read, and a parse that fails at the last byte has wasted all of it. For large collections, the common alternative is one JSON value per line, called JSON Lines or newline-delimited JSON, where each line is a complete document. A reader handles a line at a time, a writer can append, and a damaged line affects one record and not the file. DuckDB reads it with <code>read_json</code> and <code>read_ndjson</code>, jq processes it as a stream, and Python's loop over lines with <code>json.loads</code> does the same in a few lines. When you control the format of a large export, choose JSON Lines over one big array (<a href=\"/library/converting-csv-json-lines-streaming\">converting CSV to JSON Lines for streaming</a> shows the conversion). When you receive a big array, use a streaming parser, such as <code>ijson</code> in Python or <code>jq --stream</code>, instead of loading it.</p>\n<h2>JSON beside its neighbors</h2>\n<p>Choosing JSON for data exchange is a choice among formats, and each has its own edges. CSV is flat, typeless and universal in spreadsheets, and has no standard for nesting. XML has schemas, namespaces, comments and attributes, and a verbose syntax with its own parser pitfalls. YAML is a superset in spirit that allows comments, anchors and many ways to write a value, with the implicit typing that turns <code>no</code> into a boolean and <code>1.10</code> into a number, which is why it is a poor format for data from outside. TOML and INI suit small configuration files. JSON sits in the middle: typed enough for APIs, small enough to parse everywhere, with no comments and no date. If you need to compare, look at what each does to your awkward values: a long ID, a date, a missing value, a text with a quote and a newline. The format that preserves them with the least ceremony is the right one for that data.</p>\n<h2>What to do with a document that is almost JSON</h2>\n<p>Most of the trouble people report is not a spec question. It is a file that is nearly JSON: a trailing comma left by a hand edit, single quotes from a Python <code>repr</code>, a JavaScript object literal pasted into a file, a truncated response, an HTML error page where JSON was expected, a BOM from an editor. Each has a specific diagnosis and a specific fix, and the error message points at it when you know how to read it. The next articles in this series go through them: <a href=\"/library/fixing-json-parse-errors-reading\">reading parse error messages</a>, <a href=\"/library/json-parse-error-single-quotes\">single quotes</a>, <a href=\"/library/trailing-commas-json\">trailing commas</a>, <a href=\"/library/json-has-no-comments\">comments</a> and the others. Repair is a last resort: where you can, fix the producer so that the documents are valid, and where you cannot, use a purpose-built lenient parser such as JSON5 for files that you know come from humans, rather than regular expressions that guess.</p>\n<h2>When differences become security problems</h2>\n<p>Disagreement between parsers is not only an annoyance. If two components read the same JSON and take different values from it, an attacker can write a document that one component validates and the other acts on. Duplicate keys are the classic case: <code>{\"role\": \"user\", \"role\": \"admin\"}</code> validated by a parser that keeps the first value and executed by one that keeps the last. A lone surrogate, a huge number or a differently handled BOM can do similar work. Nicolas Seriot's survey, <a href=\"https://seriot.ch/projects/parsing_json.html\" target=\"_blank\" rel=\"noopener noreferrer\">Parsing JSON is a Minefield</a>, tested dozens of parsers against hundreds of inputs and documented how much they vary, which is the larger version of the experiment here.</p>\n<p>Defenses are practical. Parse once, at the boundary, and pass the parsed structure inward, never the text. Reject duplicate keys where you can (in Python through <code>object_pairs_hook</code>, shown in <a href=\"/library/duplicate-keys-json-object\">duplicate keys in a JSON object and which one wins</a>). Validate against a schema after parsing. And limit size and depth, since a deeply nested document can exhaust a parser's stack.</p>\n<h2>Writing and reading safely</h2>\n<p>The advice splits by direction. When you produce JSON, produce the strict, boring subset that every parser agrees on. RFC 7493, the I-JSON profile, is a written version of it: object member names unique, numbers within the range of an IEEE double, no non-finite numbers, UTF-8 and no unpaired surrogates, and dates and binary data as strings in agreed encodings. Write IDs above 2 to the 53rd as strings, write times as ISO 8601 strings, omit fields instead of writing <code>NaN</code>, and write documents with a library and never by hand-concatenating strings.</p>\n<p>When you consume JSON, be as strict as your needs allow, and say so. Use the parser's strict mode and turn off non-standard extensions (<code>allow_nan=False</code> in Python's <code>json.loads</code> with a <code>parse_constant</code> that raises). Handle the BOM on purpose, since files saved by Windows tools often have one. Decide what to do about duplicates. Limit depth and size. And read errors properly: the message tells you the position and the expectation, and <a href=\"/library/fixing-json-parse-errors-reading\">fixing JSON parse errors by reading the message properly</a> shows how to use them.</p>\n<h2>Test your own parser</h2>\n<p>If JSON from outside enters your system, test your parser with the awkward inputs, because its behavior is the one that matters. Keep the 26 inputs, or a larger set such as the JSONTestSuite, in a folder, run them through the code path you use in production and write down the accept and reject list. Check it again when you upgrade the library or the runtime. <a href=\"/library/cheat-sheet-json-edge-cases\">A cheat sheet of JSON edge cases to throw at a parser</a> has the set, the scripts that produced the table above and the expected results for each case. A parser's behavior at these edges is stable for years and changes without much notice when a major version arrives, so a test that fails on upgrade is useful information.</p>\n<h2>When strictness is the wrong goal</h2>\n<p>Strictness is for data exchanged between programs. For files that people edit, configuration, settings, fixtures, a format with comments and trailing commas is kinder, and the right move is to choose one openly instead of bending JSON: JSONC for editor settings, JSON5 for human-edited config, YAML or TOML where those fit (<a href=\"/library/json-json5-jsonc\">JSON versus JSON5 versus JSONC and when to use each</a> compares them). A file with comments in a <code>.json</code> file works in the tool that expects it and is a bug waiting in any other. The authority for the grammar is <a href=\"https://www.rfc-editor.org/rfc/rfc8259\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8259</a>, with the interoperability profile in <a href=\"https://www.rfc-editor.org/rfc/rfc7493\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 7493</a>, and Python's behavior is documented in the <a href=\"https://docs.python.org/3/library/json.html\" target=\"_blank\" rel=\"noopener noreferrer\">json module reference</a>.</p>","date_published":"2026-11-03T00:07:00+00:00","tags":["JSON"]},{"id":"https://datahacks.dev/library/csv-round-tripping-exports-back-imports","url":"https://datahacks.dev/library/csv-round-tripping-exports-back-imports","title":"Round Tripping Exports Back Into Imports Without Drift","summary":"A CSV export should re-import to the same data. A test where identical files hid a NULL turned into an empty string, and how to test round trips on rows.","content_html":"<p>\"Export, edit in a spreadsheet, import\" is a workflow users expect to work. They download their customers, fix forty cells, upload the file and assume that the other 3,960 rows come back as they were. Whether they do is a property you can test, and the test most people write is the wrong one. They export twice and compare the files, find them identical and conclude the feature is safe. This article builds the right test, shows with a real run how the wrong one passes while the data changes and goes through the places where a CSV round trip loses information. It is one of the contracts discussed in <a href=\"/library/csv-import-export-features-applications\">CSV import and export features in applications</a>, and the NULL question it exposes is the same one that <a href=\"/library/handling-null-empty-strings-postgresql\">handling null and empty strings in PostgreSQL</a> examines from the database side.</p>\n<h2>The experiment</h2>\n<p>A source table with four rows, each testing a different hazard:</p>\n<table><thead><tr><th>id</th><th>name</th><th>note</th><th>active</th><th>balance</th></tr></thead><tbody><tr><td>1</td><td><code>Acme Ltd</code></td><td>NULL</td><td>1</td><td><code>120.50</code></td></tr><tr><td>2</td><td><code>Beta, Corp</code></td><td>empty string</td><td>0</td><td><code>0.00</code></td></tr><tr><td>3</td><td><code>Cy \"Foods\"</code></td><td><code>two</code> newline <code>lines</code></td><td>1</td><td><code>15.00</code></td></tr><tr><td>4</td><td><code>  Delta  </code> (padded)</td><td><code>x</code></td><td>1</td><td><code>007.10</code></td></tr></tbody></table>\n<p>The steps: export the table to a CSV with <code>fputcsv</code>, import that file into a second, identical table, and export the second table. Then compare. The CSV that comes out of the first export is:</p>\n<pre><code class=\"language-csv\">id,name,note,active,balance\n1,\"Acme Ltd\",,1,120.50\n2,\"Beta, Corp\",,0,0.00\n3,\"Cy \"\"Foods\"\"\",\"two\nlines\",1,15.00\n4,\"  Delta  \",x,1,007.10</code></pre>\n<p>Look at rows 1 and 2. The note is empty in both, and the file does not say which was NULL and which was an empty string. (PHP quotes <code>Acme Ltd</code> because it contains a space, a quirk of <code>fputcsv</code>, harmless but worth knowing when you compare against files from other writers.) The comma, the quotes, the line break, the padding and the leading zero in <code>007.10</code> are all preserved: quoting and doubling handle the first three, and the last two are plain text in a text column.</p>\n<h2>The comparison that lies</h2>\n<p>Compare the first export with the second:</p>\n<pre><code class=\"language-text\">second export identical to first: true</code></pre>\n<p>The files are byte for byte the same, and the test passes. Now compare the tables, row by row, looking at how each note was stored before and after:</p>\n<pre><code class=\"language-text\">rows that differ after the round trip: 1\n  id 1  note before NULL         after ''\n  id 2  note before ''           after ''</code></pre>\n<p>Row 1's note was NULL in the source and is an empty string in the copy. The round trip changed the data, and the second export, which writes NULL and empty string identically, could not show it. Any check that goes through the file format is blind to a difference that the format cannot express. That is the general lesson: compare the data in its native form, on both sides of the round trip, not the files in the middle.</p>\n<p>The other rows came through intact, including the one with the multi-line note and the quotes. In a table where the distinction between NULL and empty matters (a nullable foreign key as text, an optional field where \"unset\" differs from \"set to nothing\"), the loss is real. Where it does not matter, it is a documented simplification.</p>\n<h2>NULL versus empty, your choice</h2>\n<p>There are exactly two readings of an empty cell, and each loses something. Read an empty cell as an empty string, and a NULL becomes <code>''</code>, which is what the test above did to row 1. Read it as NULL, and rows 1 and 2 both come back as NULL:</p>\n<pre><code class=\"language-text\">--- with empty -&gt; NULL on import\n  id 1  note before NULL         after NULL\n  id 2  note before ''           after NULL</code></pre>\n<p>Row 1 is now right and row 2 is wrong. Neither policy round-trips both, because the information is not in the file. The ways out are the following.</p>\n<p>Decide that the distinction does not exist at the application level: store NULL for \"nothing\" everywhere, and normalize on write, so the table can never hold an empty string in that column. Then reading empty as NULL is lossless. This is the simplest and best answer for most text fields.</p>\n<p>Or make the file carry the difference. RFC 4180 has no rule for it, but a common convention is that an unquoted empty field is NULL and a quoted empty field (<code>\"\"</code>) is an empty string, which PostgreSQL's <code>COPY ... CSV</code> follows (<a href=\"/library/postgresql-csv-text-format-copy\">PostgreSQL CSV versus text format in COPY</a>). The writer must then force quotes on empty strings, which PHP's <code>fputcsv</code> does not do, and the reader must distinguish quoted from unquoted, which many parsers hide. Or use an explicit marker such as <code>\\N</code> or <code>NULL</code>, documented, with the risk that a real value <code>NULL</code> collides.</p>\n<p>Or accept the loss and say so: \"Empty cells are imported as empty text.\" That is honest, and it is better than leaving it to chance.</p>\n<h2>Other places a round trip loses data</h2>\n<p>The table above had four rows, and several other hazards can be tested the same way. <strong>Types</strong>: numbers written with trailing zeros (<code>120.50</code>) come back as <code>120.5</code> if the import parses them to floats and the export formats them again; keep decimals as decimals or as text and format explicitly. <strong>Dates and time zones</strong>: a timestamp exported in local time and imported as UTC moves by the offset; write ISO 8601 with an offset. <strong>Booleans</strong>: <code>1</code> and <code>0</code> out, <code>true</code> and <code>false</code> in, or the reverse; accept both on import and pick one for export. <strong>Whitespace</strong>: padding survived in the test, and many importers trim, which changes the data and deserves a rule of its own. <strong>Encoding</strong>: the writer's UTF-8 and the reader's default code page. <strong>Line endings</strong>: embedded line breaks may turn into <code>\\r\\n</code> after passing through a Windows tool. <strong>Order</strong>: unless the export sorts, the order of rows is unspecified, so a comparison by position fails and one by key does not.</p>\n<p>Spreadsheets add their own. Excel opening the exported file and saving it can strip leading zeros from <code>007.10</code> if the cell was typed as a number, round long numbers to 15 digits, convert <code>1-2</code> to a date and rewrite line endings (<a href=\"/library/opening-csv-files-excel\">opening CSV files in Excel and Sheets without damage</a>). A round trip through a spreadsheet is a different test from a round trip through code, and it is the one your users do. Test both: write a file, open it in the spreadsheet your users use, save as CSV and import it.</p>\n<h2>The assertion</h2>\n<p>The test belongs in your suite, and it is short. Load fixture rows that hit each hazard into the source table. Export. Import the file into an empty target table. Compare the two tables, by key, column by column, with an equality that treats NULL as unequal to the empty string, and fails with the row and column of the first difference. Then assert the second export equals the first as a cheap secondary check, knowing it is secondary.</p>\n<p>The fixture matters more than the code. Include a NULL, an empty string, a value with a comma, a quote, a line break, leading and trailing spaces, a leading zero, a long number, a date at midnight, a negative decimal, a non-ASCII name and a value that starts with <code>=</code>. If your system cannot round-trip one of them, the test should say so, and you decide whether to fix it or document it. Keep the fixture in the repository, add a row for every bug a user reports and run the test in CI. A round-trip test that checked only the happy path is a test that passes on the day before the incident.</p>\n<h2>When a round trip cannot be lossless</h2>\n<p>Some data cannot go through CSV unchanged. Binary values, nested structures, very large numbers and values with mixed types in one column all need an encoding that the file does not give you. A relation with line items does not fit in one flat table without a convention. For those, the honest design says which columns are exportable and which are not, and offers a different format (JSON, a zip of files) for the whole record. If the feature promises \"edit and re-import\", restrict it to the columns that survive, and state the limits in the template and the interface, since a user who loses a nuance in a note field will blame the tool and not the format.</p>\n<p>Also be clear about the direction that is allowed to be lossy: an export for reading, such as a report, can drop information and format values for humans, and an export meant to be re-imported cannot. Build them as separate outputs, from the same column definitions, with names that tell users which is which. The behavior of the PHP functions is documented in the <a href=\"https://www.php.net/manual/en/function.fgetcsv.php\" target=\"_blank\" rel=\"noopener noreferrer\">fgetcsv</a> and <a href=\"https://www.php.net/manual/en/function.fputcsv.php\" target=\"_blank\" rel=\"noopener noreferrer\">fputcsv</a> pages, and the format in <a href=\"https://www.rfc-editor.org/rfc/rfc4180\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 4180</a>.</p>","date_published":"2026-11-02T15:11:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/csv-analytics-export-percent-signs","url":"https://datahacks.dev/library/csv-analytics-export-percent-signs","title":"An Analytics Export With Percent Signs and Dashes Cleaned","summary":"An analytics CSV writes numbers as text: 12,430, 42.5%, $1,204.50, (4.1%) and dashes. Convert them with one macro and prove nothing is left unparsed.","content_html":"<p>Exports from analytics tools are written for people. Visits come as <code>12,430</code>, rates as <code>42.5%</code>, revenue as <code>$1,204.50</code>, a change as <code>+8.2%</code> or <code>(4.1%)</code>, and anything unavailable as a dash, two dashes, <code>n/a</code> or a typographic em dash, depending on which column and which day. Opened in a spreadsheet, it looks right. Loaded into a database or a script as numbers, every one of those cells fails. The cleaning is mechanical and the risk is in the details: a placeholder missed becomes a zero or an error, a parenthesis read wrongly flips a sign. This article cleans a four-row file with one function, shows the proof that nothing escaped and discusses what the placeholders mean. It is one of the problems in <a href=\"/library/worked-csv-problems-real-mailboxes\">worked CSV problems and the patterns behind them</a>, and a close relative of <a href=\"/library/turning-currency-strings-numbers-csv\">turning currency strings into numbers in a CSV</a>.</p>\n<h2>Look at the shapes</h2>\n<p>Here is the file. Every value is display text:</p>\n<pre data-filename=\"analytics.csv\"><code class=\"language-csv\">page,visits,bounce_rate,conversion,revenue,change\n/home,\"12,430\",42.5%,3.1%,\"$1,204.50\",+8.2%\n/pricing,\"3,018\",-,--,$0.00,(4.1%)\n/blog/csv,\"8,764\",61.0%,0.4%,\"$310.00\",-1.5%\n/contact,0,n/a,n/a,—,</code></pre>\n<p>Profile the shapes with digits replaced by <code>9</code>, as in <a href=\"/library/worked-csv-problems-real-mailboxes\">worked CSV problems</a>, and each column states its own rules. <code>bounce_rate</code> has <code>99.9%</code>, a lone <code>-</code> and <code>a/a</code> (the letters of <code>n/a</code> replaced by <code>a</code>). <code>revenue</code> has <code>$9,999.99</code>, <code>$9.99</code>, <code>$999.99</code> and a single <code>—</code>. <code>change</code> has <code>(9.9%)</code>, <code>+9.9%</code> and <code>-9.9%</code>, and one empty value. From those shapes the rules write themselves: the <code>%</code> and <code>$</code> are decoration, the commas are thousands separators, a leading <code>+</code> is decoration, parentheses mean negative, and four tokens (<code>-</code>, <code>--</code>, <code>n/a</code>, <code>—</code>) plus the empty string mean no value.</p>\n<h2>One function, applied to every column</h2>\n<p>Read everything as text and define two small macros: one that recognizes placeholders, one that converts a value.</p>\n<pre><code class=\"language-sql\">CREATE MACRO is_placeholder(s) AS s IS NULL OR trim(s) IN ('', '-', '--', '—', 'n/a', 'N/A');\n\nCREATE MACRO num(s) AS CASE\n    WHEN is_placeholder(s) THEN NULL\n    WHEN s ~ '^\\(.*\\)$' THEN -1 * TRY_CAST(regexp_replace(s, '[()%$,+\\s]', '', 'g') AS DECIMAL(18,4))\n    ELSE TRY_CAST(regexp_replace(s, '[%$,+\\s]', '', 'g') AS DECIMAL(18,4)) END;\n\nSELECT page, num(visits) AS visits, num(bounce_rate) / 100 AS bounce_rate, num(conversion) / 100 AS conversion,\n       num(revenue) AS revenue, num(change) / 100 AS change\nFROM read_csv('analytics.csv', all_varchar=true);</code></pre>\n<pre><code class=\"language-text\">page       visits   bounce_rate  conversion  revenue  change\n/home      12430    0.425        0.031       1204.5   0.082\n/pricing   3018     NULL         NULL        0.0      -0.041\n/blog/csv  8764     0.610        0.004       310.0    -0.015\n/contact   0        NULL         NULL        NULL     NULL</code></pre>\n<p>Walk through the conversions. <code>12,430</code> lost its comma and became 12430. <code>42.5%</code> lost the sign and, divided by 100, became 0.425, a proportion, which is the form a database should store: store 0.425 and format it as a percent when displaying. <code>$1,204.50</code> became 1204.5. <code>+8.2%</code> became 0.082. <code>(4.1%)</code> matched the parenthesis rule, was stripped to <code>4.1</code>, multiplied by minus one and divided by 100, giving -0.041. The placeholders became NULL, which is what \"no value\" should be.</p>\n<p>A few details of the macros matter. The conversion uses <code>TRY_CAST</code>, which returns NULL for a value it cannot read instead of raising, so a new junk token does not stop the load, and for that reason the check below is not optional. The regular expression removes the characters you listed and nothing else, so a letter left in a value, such as <code>12.5k</code> or <code>3.1x</code>, survives and fails the cast, which is correct. The <code>DECIMAL(18,4)</code> type keeps four decimal places, enough for a percentage given to two. And the macro takes the whole cell as text, so the same function works on every column, which is why the cleaning is a few lines and not a script per column.</p>\n<h2>The proof: nothing escaped</h2>\n<p>Because <code>TRY_CAST</code> turns anything unreadable into NULL, a NULL in the output is ambiguous: it could be a placeholder you intended or junk you did not. Separate the two by counting values that are not placeholders and still did not parse:</p>\n<pre><code class=\"language-sql\">SELECT sum(CASE WHEN NOT is_placeholder(v) AND num(v) IS NULL THEN 1 ELSE 0 END) AS unparsed\nFROM (SELECT visits AS v FROM raw UNION ALL SELECT bounce_rate FROM raw UNION ALL SELECT conversion FROM raw\n      UNION ALL SELECT revenue FROM raw UNION ALL SELECT change FROM raw);</code></pre>\n<pre><code class=\"language-text\">unparsed\n0</code></pre>\n<p>Zero, on twenty cells. Wire it as a hard failure: if the count is not zero, stop and print the offending values, because it means a token you have not met, perhaps <code>N.A.</code>, <code>null</code> or a currency code. A second proof is a total: the visits summed to 24,212 after cleaning, and the same figure computed by hand from the original, 12,430 plus 3,018 plus 8,764 plus 0, is 24,212. Totals that match a number published elsewhere, such as a dashboard's headline figure, are the strongest evidence that the cleaning kept the meaning.</p>\n<h2>What a dash means</h2>\n<p>The macros turn four different tokens into NULL, and it is worth asking whether they mean the same thing. <code>-</code> in a bounce rate column often means the metric could not be computed, because there were no sessions to compute a rate from. <code>n/a</code> may mean the same or \"not applicable for this page type\". The em dash in revenue for <code>/contact</code> may mean no revenue tracking is set up, while <code>$0.00</code> for <code>/pricing</code> means tracking works and nothing was earned. Converting both to NULL loses a distinction, and converting both to zero invents data: a zero revenue and an untracked page are different facts, and an average that includes zeros for untracked pages is wrong.</p>\n<p>So decide per column, write it down and keep the information if it matters. A cheap way is to preserve the original in a flag column: <code>revenue_raw</code> beside <code>revenue</code>, or a <code>revenue_status</code> with <code>ok</code>, <code>no_data</code> and <code>not_tracked</code>. Averages should then be over rows that have a value, with the count of rows reported. In the test file, <code>/contact</code> shows 0 visits and no rates: zero visits explains why a bounce rate cannot exist, and a rule of \"a missing rate with zero visits is expected\" turns a mystery NULL into a checked one.</p>\n<h2>Percent as a proportion</h2>\n<p>Storing 42.5 versus 0.425 is a source of long-running confusion. Pick one. I prefer proportions (0.425) in storage, since they multiply and combine without conversion, and a column named <code>bounce_rate</code> holding 0.425 is clear when the type or comment says so. If the target system wants percent as 42.5, say so in the column name (<code>bounce_rate_pct</code>). The bug to avoid is a feed in which some rows are 0.425 and some 42.5 because the source mixed conventions: check the range, which for a proportion should lie between 0 and 1, and flag rows outside it.</p>\n<h2>When a placeholder means something</h2>\n<p>If the export is the only record of why a number is missing, do not discard it. Analytics tools often use distinct tokens for distinct reasons, and a new token appearing in the file is information: a tracking change, an outage, a new page type. The <code>unparsed</code> check surfaces it. Add the token to the list only after you know what it means, and put the meaning in a comment. For the broader decision about missing values, see <a href=\"/library/handling-missing-values-csv\">handling missing values in a CSV</a>. The functions used are documented in DuckDB's <a href=\"https://duckdb.org/docs/2.0/sql/functions/regular_expressions\" target=\"_blank\" rel=\"noopener noreferrer\">regular expression reference</a> and its <a href=\"https://duckdb.org/docs/2.0/sql/data_types/numeric\" target=\"_blank\" rel=\"noopener noreferrer\">numeric types page</a>.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/preserving-comments-order-ruamel-yaml","url":"https://datahacks.dev/library/preserving-comments-order-ruamel-yaml","title":"Preserving Comments and Order With ruamel yaml Round Trips","summary":"Edit a YAML file from Python and keep its comments, key order, quotes and block strings with ruamel.yaml round-trip mode, plus settings and caveats.","content_html":"<p>A script that edits a configuration file has an easy job and a hard constraint: leave everything else alone. The file has comments that explain why values are what they are, a key order that people chose, quotes that matter and a layout that the team agreed on. A library that loads into plain dictionaries and writes them back destroys all of it. <code>ruamel.yaml</code> is the Python library built for this job, and it takes a few settings to get a clean result. Here is the test with the output, and the settings.</p>\n<h2>The sample</h2>\n<pre><code class=\"language-yaml\"># Service settings\nservice:\n  name: \"web\"        # quoted on purpose\n  ports:\n  - 80\n  - 443\n  tags: [a,   b]\n  note: |\n    keep this\n    block\n  retries: 3</code></pre>\n<p>The edit: change <code>retries</code> to 5, add a <code>region</code> key with a comment.</p>\n<h2>What round-trip mode does</h2>\n<pre><code class=\"language-python\">from ruamel.yaml import YAML\ny = YAML()\ny.preserve_quotes = True\ny.indent(mapping=2, sequence=4, offset=2)\nd = y.load(src)\nd[\"service\"][\"retries\"] = 5\nd[\"service\"][\"region\"] = \"eu\"\nd[\"service\"].yaml_add_eol_comment(\"added by script\", \"region\")\ny.dump(d, sys.stdout)</code></pre>\n<pre><code class=\"language-text\"># Service settings\nservice:\n  name: \"web\"        # quoted on purpose\n  ports:\n    - 80\n    - 443\n  tags: [a, b]\n  note: |\n    keep this\n    block\n  retries: 5\n  region: eu         # added by script</code></pre>\n<p>The header comment, the quoted name and its trailing comment, the flow list (with its odd spacing normalized), the block string and the order of keys are all intact. The changed value shows the new number, and the added key carries its own comment, aligned with the other trailing comment. <code>ports</code> is indented under its key because of <code>indent(mapping=2, sequence=4, offset=2)</code>, which I chose to match a layout the team might use.</p>\n<p>The loaded object behaves like a dictionary (<code>d[\"service\"][\"retries\"]</code>) and a list, so most code that reads or edits needs no change. The extra methods are for comments: <code>yaml_add_eol_comment</code>, <code>yaml_set_start_comment</code> and <code>yaml_set_comment_before_after_key</code>. Comments attach to keys and list items, and they survive when you move or modify the values around them, but not when you delete the key they are attached to.</p>\n<h2>Without the settings</h2>\n<p>Round-trip mode with the defaults still keeps comments and order, with small differences:</p>\n<pre><code class=\"language-python\">y2 = YAML(); d2 = y2.load(src); d2[\"service\"][\"retries\"] = 5</code></pre>\n<pre><code class=\"language-text\"># Service settings\nservice:\n  name: web          # quoted on purpose\n  ports:\n  - 80\n  - 443\n  tags: [a, b]\n  note: |\n    keep this\n    block\n  retries: 5</code></pre>\n<p>The quotes around <code>web</code> are gone because <code>preserve_quotes</code> defaults to <code>False</code>, and the trailing comment moved over to column 20 because the value got shorter. The list stays at the key's indentation because that is the default, which preserves the original here. If your file has a style, set <code>preserve_quotes = True</code> and the indentation, and compare the result with the original through a diff before writing it. If the diff has only the intended change, the settings match your file.</p>\n<h2>What PyYAML does to the same edit</h2>\n<pre><code class=\"language-python\">yaml.safe_dump(yaml.safe_load(src), sort_keys=False)</code></pre>\n<pre><code class=\"language-text\">service:\n  name: web\n  ports:\n  - 80\n  - 443\n  tags:\n  - a\n  - b\n  note: 'keep this\n\n    block\n\n    '\n  retries: 3</code></pre>\n<p>The header comment and the trailing comment are gone, <code>\"web\"</code> lost its quotes, the flow list became a block list, and the block string became a single-quoted scalar with blank lines. Nothing here is a bug in PyYAML. It builds plain Python data that cannot hold comments or layout, so it writes the data in its own style. For read-only use that is fine. For editing, use <code>ruamel.yaml</code> or a structure-aware tool like <code>yq</code>, which also keeps comments (with small spacing changes) (<a href=\"/library/comments-yaml-parsers\">comments in YAML and what parsers do with them</a>).</p>\n<h2>Details that matter</h2>\n<p><strong>YAML 1.2.</strong> <code>ruamel.yaml</code> defaults to the 1.2 rules, so <code>NO</code> is a string and <code>12:30</code> is not a number, and its output is written accordingly. A file that depends on 1.1 booleans will read differently. If you need 1.1 behavior, set <code>y.version = (1, 1)</code>.</p>\n<p><strong>Line width.</strong> By default long lines are wrapped at 80 columns. Set <code>y.width = 4096</code> to stop the library from rewrapping long values that you did not change.</p>\n<p><strong>Explicit document start.</strong> If the original starts with <code>---</code>, set <code>y.explicit_start = True</code> to keep it.</p>\n<p><strong>Duplicate keys.</strong> Round-trip mode raises <code>DuplicateKeyError</code> by default, a useful side effect when editing.</p>\n<p><strong>Anchors and merge keys.</strong> They are preserved as anchors and aliases and reproduced on dump, and merge keys are kept as <code>&lt;&lt;</code> entries. Editing a value inside an anchored mapping changes it for all aliases, as in the file's meaning.</p>\n<p><strong>Types.</strong> Integers keep their written form where possible (hex, octal with underscores are remembered), and floats keep their formatting, which is why a version <code>3.10</code> can be written back as <code>3.10</code> in round-trip mode, though the parsed value is still the float 3.1. Do not rely on it for versions; quote them.</p>\n<p><strong>Speed.</strong> On a 1.4 MB document, round-trip loading took 7.52 seconds, <code>ruamel.yaml</code> safe mode 5.49 and PyYAML's C loader 0.67. This is a tool for files people read, not for large data.</p>\n<h2>A safe editing pattern</h2>\n<ol><li><p>Load with <code>YAML()</code> and the settings above.</p></li><li><p>Make the minimal change in the loaded structure.</p></li><li><p>Dump to a string, not straight over the file.</p></li><li><p>Diff the string against the original. If any line other than the intended ones changed, adjust the settings or fall back to an in-place edit with <code>yq</code>.</p></li><li><p>Write the file, and reload it in a test to check the values.</p></li><li><p>Keep a backup or rely on version control, and commit the change with its diff.</p></li></ol>\n<p>Automating step 4 is worth the five lines. A script that edits a hundred files and refuses to write any file whose diff is larger than expected stops a formatting regression from landing in a hundred places (<a href=\"/library/yq-update-many-yaml-files\">using yq to update many YAML files at once</a> shows a bulk-edit workflow).</p>\n<h2>Adding structure without losing layout</h2>\n<p>Beyond changing a value, scripts often need to insert a whole new block, append to a list or remove an entry. The round-trip containers support the operations you expect from dictionaries and lists, with the comment machinery carried along. Appending to a list with <code>d[\"service\"][\"ports\"].append(8080)</code> keeps the existing comments and adds an item in the same style. Inserting a key at a specific position uses <code>insert(pos, key, value)</code> on the mapping, which matters when the file has a convention for the order of keys, such as <code>name</code> first. Removing a key with <code>del</code> removes its trailing comment as well, and the comment above it stays attached to the next key, which can leave a stray comment that now describes the wrong thing. After a deletion, read the diff and move any orphaned comment.</p>\n<p>For a new nested block, build it with the same classes the loader uses so that the dumper formats it like the rest of the file: <code>from ruamel.yaml.comments import CommentedMap</code>, create a <code>CommentedMap</code>, fill it and assign it. A plain dictionary also works, and is written in block style with the document's indentation settings. If you want a particular block to be written in flow style, call <code>.fa.set_flow_style()</code> on the container.</p>\n<p>Finally, test the edit script on a copy of a real file before pointing it at a directory. Run it, diff the result and read every changed line. Round-trip libraries are good, and files in the wild contain constructs that surprise them: unusual comment placement, anchors nested in lists, tabs in comments, folded scalars with odd indentation. The diff is how you find out, and the cost is a minute.</p>\n<h2>Related reading</h2>\n<p>The cluster hub is <a href=\"/library/reading-writing-yaml-python-safely\">reading and writing YAML in Python safely</a>. The simpler tool for read-only loading is <a href=\"/library/loading-yaml-python-pyyaml-safe\">loading YAML in Python with PyYAML safe_load</a>, and the writing side with PyYAML in <a href=\"/library/writing-yaml-python\">writing YAML from Python without ugly output</a>. Documentation: the <a href=\"https://yaml.dev/doc/ruamel.yaml/\" target=\"_blank\" rel=\"noopener noreferrer\">ruamel.yaml documentation</a> and its <a href=\"https://pypi.org/project/ruamel.yaml/\" target=\"_blank\" rel=\"noopener noreferrer\">PyPI page</a>. Look at the result in the <a href=\"/tools/yaml-formatter\">YAML formatter</a>.</p>\n<h2>When round-tripping with ruamel is not enough</h2>\n<p><strong>You need byte-identical output.</strong> No library guarantees it. Whitespace inside flow collections, comment alignment and line wrapping can change. Use text-level edits for tiny changes.</p>\n<p><strong>You edit many files.</strong> Round-trip tools are slow per file and drift between versions. Pin the version and check diffs.</p>\n<p><strong>You need other formats too.</strong> For TOML or JSON with comments, other tools apply.</p>\n<p><strong>The file is generated.</strong> If a program regenerates the file on each run, comment preservation is moot. Document the generator and keep the comments there.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["YAML"]},{"id":"https://datahacks.dev/library/loading-multiple-documents-one-yaml","url":"https://datahacks.dev/library/loading-multiple-documents-one-yaml","title":"Loading Multiple Documents From One YAML File in Python","summary":"A YAML file can hold several documents separated by three dashes. safe_load fails on them, safe_load_all reads them and safe_dump_all writes them.","content_html":"<p>Kubernetes manifests, multi-resource Compose overrides, CI pipeline definitions and exported datasets often put several YAML documents in one file. The separator is a line with three dashes. If you load such a file with the function you use for single documents, it fails. If you load it with the right function, you get a generator you have to treat with care. This post covers the format and the Python calls, with the output.</p>\n<h2>The format</h2>\n<p>A YAML stream is a sequence of documents. A document starts at the beginning of the stream or after a line of three dashes (<code>---</code>), and it ends at the next <code>---</code>, at a line of three dots (<code>...</code>) or at the end of the file:</p>\n<pre><code class=\"language-yaml\">---\na: 1\n---\nb: 2\n---\n- x</code></pre>\n<p>The first document is a mapping, the second another mapping, the third a list. The first <code>---</code> is optional (a file without it is a single document). Each document is independent: it has its own root node, its own anchors and its own directives. A common real example is a Kubernetes file that has a <code>Deployment</code> document, a <code>Service</code> document and a <code>ConfigMap</code> document back to back.</p>\n<h2>Loading: safe_load fails, safe_load_all works</h2>\n<pre><code class=\"language-python\">docs = \"---\\na: 1\\n---\\nb: 2\\n---\\n- x\\n\"\nprint(list(yaml.safe_load_all(docs)))\ntry:\n    yaml.safe_load(docs)\nexcept yaml.YAMLError as e:\n    print(\"safe_load on 3 docs:\", type(e).__name__, str(e).split(\"\\n\")[0][:80])</code></pre>\n<pre><code class=\"language-text\">[{'a': 1}, {'b': 2}, ['x']]\nsafe_load on 3 docs: ComposerError expected a single document in the stream</code></pre>\n<p><code>safe_load_all</code> returned the three documents: two dictionaries and a list. <code>safe_load</code> refused, with a <code>ComposerError</code> that says exactly what is wrong. Note that <code>safe_load</code> fails only when there is more than one document. A file with a leading <code>---</code> and a single document loads fine, and a file whose second document is empty may or may not, depending on how you wrote the separator. Code that must accept either shape can use <code>safe_load_all</code> and take the first element, or check the length.</p>\n<p>Another detail: a file of only comments or whitespace yields no documents from <code>safe_load_all</code> and <code>None</code> from <code>safe_load</code>. A file whose last line is <code>---</code> yields a final document of <code>None</code>, which surprises code that iterates and indexes each document. Filter: <code>docs = [d for d in yaml.safe_load_all(f) if d is not None]</code>.</p>\n<h2>The generator</h2>\n<p><code>safe_load_all</code> returns a generator, and the work happens as you iterate. If the input is an open file, the file must still be open then:</p>\n<pre><code class=\"language-python\">with open(\"stack.yaml\", encoding=\"utf-8\") as f:\n    docs = yaml.safe_load_all(f)       # nothing parsed yet\n# file closed here: iterating docs now raises ValueError (I/O on closed file)\n\nwith open(\"stack.yaml\", encoding=\"utf-8\") as f:\n    docs = list(yaml.safe_load_all(f))   # parsed while the file is open</code></pre>\n<p>The first version looks right and fails when you use <code>docs</code>. Either consume the generator inside the <code>with</code> block (<code>for d in yaml.safe_load_all(f): ...</code>) or turn it into a list. For very large streams the loop is better, since it holds one document at a time. A syntax error in the third document is raised while you are iterating to it, after you have already processed the first two. For all-or-nothing behavior, build the list first.</p>\n<h2>Writing several documents</h2>\n<pre><code class=\"language-python\">print(yaml.safe_dump_all([{\"a\": 1}, {\"b\": 2}], explicit_start=True), end=\"\")</code></pre>\n<pre><code class=\"language-text\">---\na: 1\n---\nb: 2</code></pre>\n<p><code>safe_dump_all</code> takes an iterable of objects and writes each as a document. With <code>explicit_start=True</code> every document, including the first, begins with <code>---</code>, which is what most tools expect and what makes the output unambiguous. Without it, the first document has no marker. Add <code>sort_keys=False</code> and the other options from the single-document <code>safe_dump</code> (<a href=\"/library/writing-yaml-python\">writing YAML from Python without ugly output</a>).</p>\n<h2>Anchors stay inside a document</h2>\n<p>An anchor defined in one document is not visible in the next:</p>\n<pre><code class=\"language-yaml\">---\nbase: &amp;b {x: 1}\nuse: *b\n---\nother: *b</code></pre>\n<p>The second document fails with an error about an undefined alias, because <code>b</code> was defined in the first. If several documents share definitions, repeat them, or load the shared part separately and merge in code. The same isolation protects you: a document cannot accidentally depend on a distant definition.</p>\n<h2>Working with them on the command line</h2>\n<p><code>yq</code> handles streams: <code>yq '.a' file.yaml</code> runs the expression on every document, and <code>yq eval-all '[.a]' -o=json -I=0 file.yaml</code> collects across them. For a stream of two documents with <code>a: 1</code> and <code>a: 2</code> it printed <code>1</code>, <code>---</code>, <code>2</code> for the first form and <code>[1,2]</code> for the second. Use <code>select(document_index == 1)</code> to pick one. Splitting a stream into files is <code>yq -s '.metadata.name' stack.yaml</code>, which writes one file per document named by the expression (<a href=\"/library/yq-update-many-yaml-files\">using yq to update many YAML files at once</a> covers bulk workflows).</p>\n<h2>Patterns</h2>\n<p><strong>Iterate and dispatch by kind.</strong> For a Kubernetes-style stream, loop over the documents and route each by its <code>kind</code> key. Validate each document against its own schema.</p>\n<p><strong>Keep order.</strong> The order of documents in the file is meaningful for some tools (a namespace must exist before the resources in it). <code>safe_load_all</code> preserves it, and so should your writer.</p>\n<p><strong>Count and report.</strong> When a stream is malformed, report which document (by index) failed, not only the line. A counter in the loop gives you that.</p>\n<p><strong>Prefer separate files for unrelated data.</strong> If the documents have nothing to do with each other, several files are easier to diff, review and validate.</p>\n<h2>Edge cases tested</h2>\n<p>Three small cases behave in ways that surprise people. A file ending with a lone separator line produced an extra document: loading <code>a: 1</code> followed by <code>---</code> gave <code>[{'a': 1}, None]</code>, so a loop that indexes each document fails on the trailing <code>None</code>. A file containing only a comment gave an empty list from <code>safe_load_all</code> and <code>None</code> from <code>safe_load</code>, which are different empty results. And the stream-with-leading-separator <code>---</code> and one mapping loaded through plain <code>safe_load</code> as <code>{'a': 1}</code>, so a leading marker alone does not make a file multi-document.</p>\n<p>The closed-file case is the one that bites in practice: creating the generator inside a <code>with</code> block and using it after the block raised <code>ValueError: I/O operation on closed file</code>. Because the parse is lazy, the error appears at the point of use, far from the cause. Make the habit automatic: either loop inside the block or call <code>list(...)</code> there.</p>\n<p>If you validate each document, give the error the index. Wrapping the loop in <code>enumerate(yaml.safe_load_all(f), start=1)</code> lets you report \"document 3 of the stream is invalid\", which saves a search through a long file.</p>\n<h2>Related reading</h2>\n<p>The cluster hub is <a href=\"/library/reading-writing-yaml-python-safely\">reading and writing YAML in Python safely</a>. The single-document call is explained in <a href=\"/library/loading-yaml-python-pyyaml-safe\">loading YAML in Python with PyYAML safe_load</a>. Document markers are defined in section 9 of the <a href=\"https://yaml.org/spec/1.2.2/#chapter-9-document-stream-productions\" target=\"_blank\" rel=\"noopener noreferrer\">YAML 1.2.2 specification</a>, and PyYAML's functions in its <a href=\"https://pyyaml.org/wiki/PyYAMLDocumentation\" target=\"_blank\" rel=\"noopener noreferrer\">documentation</a>. Convert one document to JSON in the <a href=\"/tools/yaml-json\">YAML to JSON tool</a>.</p>\n<h2>When multi-document YAML is the wrong container</h2>\n<p><strong>The consumer expects one document.</strong> Many tools read the first document only or reject a stream. Check before combining.</p>\n<p><strong>The documents are unrelated.</strong> A bundle of independent things in one file makes diffs, ownership and validation harder.</p>\n<p><strong>You need to merge them.</strong> If the intent is one configuration made of parts, use separate files and a merge step, or anchors within one document (<a href=\"/library/splitting-large-yaml-config-several\">splitting a large YAML config into several files</a>).</p>\n<p><strong>The data is a long list of records.</strong> A list in one document is simpler than hundreds of documents, and parsers handle it better.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["YAML"]},{"id":"https://datahacks.dev/library/csv-import-export-features-applications","url":"https://datahacks.dev/library/csv-import-export-features-applications","title":"CSV Import and Export Features in Applications Done Right","summary":"Build CSV import and export into an application: staged imports with dry runs, streamed and background exports, column names, round trips and safety.","content_html":"<p>Almost every business application gets two requests within its first year: \"can I upload a spreadsheet to create these in bulk\" and \"can I download this list as a CSV\". They sound small. The first turns out to be a data-quality problem with a user interface, and the second a performance problem that appears the day a customer has enough rows. Both are shaped by the same fact: a CSV is a file written by humans and other programs, in whatever dialect they like, and an application that treats it as a clean table will find out otherwise from the first user. This guide describes the import and export features I would build, stage by stage, with real runs of the parts that can be tested without a framework. It covers the design of the import screen, previews and dry runs, error reports, streaming and background exports, column names, round trips and the safety checks, and it points to the focused article for each. The back-end side of repeated imports from a partner is a different problem, covered in <a href=\"/library/automating-csv-imports-database\">automating CSV imports into a database</a>.</p>\n<h2>Import is a pipeline, not a button</h2>\n<p>The version of import that most teams build first is a form with a file field and a submit button. It reads the file, inserts rows and shows \"done\", and it fails in a way users cannot understand the first time a cell contains a surprise. The version that works has stages, and each stage gives the user a chance to stop or fix something before the next:</p>\n<table><thead><tr><th>Stage</th><th>What happens</th><th>What the user sees</th></tr></thead><tbody><tr><td>Upload</td><td>File arrives, size and type checked, stored outside the web root</td><td>A progress bar, a clear refusal for a file that is too large</td></tr><tr><td>Inspect</td><td>Encoding, delimiter, header and row count detected</td><td>\"We found 4 columns and 2,310 rows\"</td></tr><tr><td>Map</td><td>File headers are matched to the fields the application knows</td><td>A table of file columns and target fields, editable</td></tr><tr><td>Validate</td><td>Every row is checked, nothing is saved</td><td>A summary: ready, warnings, errors, with examples</td></tr><tr><td>Commit</td><td>Valid rows are written in a transaction</td><td>A confirmation and a count</td></tr><tr><td>Report</td><td>What was created, updated and rejected, with reasons</td><td>A downloadable file of the rows that need fixing</td></tr></tbody></table>\n<p>Every article in this cluster is one stage. <a href=\"/library/designing-csv-import-screen-users\">Designing a CSV import screen users can succeed with</a> covers the screens, <a href=\"/library/previewing-csv-upload\">previewing a CSV upload before committing</a> the dry run, and <a href=\"/library/csv-reporting-row-level-errors-back\">reporting row-level errors back to the user</a> the report. The point of the table is the order. Validation comes before commit, and nothing is saved until the user has seen what will happen.</p>\n<h2>A real import: validate, dry run, report</h2>\n<p>Here is the core of such a pipeline in plain PHP with SQLite, small enough to read in a minute. The input is a file of six customers, two of whom already exist, with one row that has an unknown plan and no seat count, one with a bad email and one with a plan the application does not offer:</p>\n<pre data-filename=\"customers.csv\"><code class=\"language-csv\">Email,Full name,Plan,Seats\nada@example.test,Ada Lovelace,pro,5\nbob@example.test,Bob Stone,free,1\ncy@example.test,Cy Young,enterprise,\nnot-an-email,Di Park,pro,3\ned@example.test,Ed Fox,gold,2\nfay@example.test,Fay Moss,team,12</code></pre>\n<p>The import function validates each row, inserts or updates inside a transaction and commits or rolls back according to a flag. The validation is deliberately plain: an email must look like an email, a plan must be one of <code>free</code>, <code>pro</code> and <code>team</code>, seats must be a whole number of at least one.</p>\n<pre><code class=\"language-php\">function runImport(PDO $pdo, string $path, bool $commit): array {\n    $pdo-&gt;beginTransaction();\n    $summary = ['created' =&gt; 0, 'updated' =&gt; 0, 'rejected' =&gt; 0];\n    $report = [];\n    $in = fopen($path, 'r');\n    fgetcsv($in, 0, ',', '\"', '');                       // header\n    $line = 1;\n    while (($f = fgetcsv($in, 0, ',', '\"', '')) !== false) {\n        $line++;\n        $row = ['email' =&gt; trim($f[0]), 'name' =&gt; trim($f[1]), 'plan' =&gt; trim($f[2]), 'seats' =&gt; trim($f[3])];\n        $errors = validate($row);\n        if ($errors) {\n            $summary['rejected']++;\n            foreach ($errors as [$col, $val, $msg]) $report[] = [$line, $col, $val, $msg];\n            continue;\n        }\n        // update when the email exists, insert otherwise ...\n    }\n    $commit ? $pdo-&gt;commit() : $pdo-&gt;rollBack();\n    return [$summary, $report];\n}</code></pre>\n<p>Run first as a dry run against a table that already holds two of the customers, with the commit flag off:</p>\n<pre><code class=\"language-text\">before: 2 customers\ndry run: {\"created\":1,\"updated\":2,\"rejected\":3} -&gt; table still has 2\nerror report:\nline,column,value,problem\n4,plan,enterprise,\"plan must be one of: free, pro, team\"\n4,seats,,\"seats must be a whole number of 1 or more\"\n5,email,not-an-email,\"not a valid email address\"\n6,plan,gold,\"plan must be one of: free, pro, team\"</code></pre>\n<p>One customer would be created, two updated and three rejected, and the table still has its two original rows, because the transaction was rolled back. Then the same call with commit on:</p>\n<pre><code class=\"language-text\">commit: {\"created\":1,\"updated\":2,\"rejected\":3} -&gt; table now has 3\n  ada@example.test | Ada Lovelace | pro | 5\n  bob@example.test | Bob Stone | free | 1\n  fay@example.test | Fay Moss | team | 12</code></pre>\n<p>The dry run and the commit are the same code path, and the only difference is the last line. That is the design to copy: a preview that runs different code from the real import will drift, and the day it says \"all clear\" and the real import fails is the day nobody trusts either. Note what the report says. Line 4 has two problems, and both are listed, since a user who is told one error per row will fix, re-upload and meet the second. Lines are physical line numbers in the user's file, with the header as line 1, so they can find the row in a spreadsheet.</p>\n<h2>Errors the user can act on</h2>\n<p>An error message is a product feature. \"Import failed\" is useless, and \"Row 4: plan 'enterprise' is not one of free, pro, team\" can be fixed in ten seconds. Four rules make the report usable. State the row and the column, in the user's terms: the file's own header (<code>Plan</code>) and not your internal field name. Show the value that failed, so a stray space or a wrong character is visible. Say what would be accepted. And give the whole list in a form the user can work with, which means a CSV of the rejected rows with an added error column, so the user fixes those rows in place and uploads only them (<a href=\"/library/csv-reporting-row-level-errors-back\">reporting row-level errors back to the user</a> has the format). Cap what you display on screen at the first hundred errors and group them by type, with the full list in the download, because a file with 40,000 bad rows is not one that a person fixes row by row, and the pattern is more useful than the list.</p>\n<h2>Export is a streaming problem</h2>\n<p>The first export feature is usually written as: query the rows, build the CSV in a string or an array, return it. It works in testing with 200 rows. At a million rows, it fails. The experiment: produce 1,000,000 rows of four columns (a 40.7 MB file) two ways in PHP, one collecting every row into an array and then writing, one writing each row as it is produced.</p>\n<pre><code class=\"language-text\">array   rows=1000000 time=1.43s peak memory=336.0 MB file=40.7 MB\nstream  rows=1000000 time=1.09s peak memory=2.0 MB file=40.7 MB</code></pre>\n<p>The array version peaked at 336 MB, and under PHP's default <code>memory_limit</code> of 128 MB it did not finish at all: <code>Fatal error: Allowed memory size of 134217728 bytes exhausted</code>. The streaming version used 2 MB, whatever the row count, and was faster. The same file, two orders of magnitude apart in memory. The principle is general: produce the file row by row, write each row to the response or a file as soon as you have it, and never hold the whole result. In a web framework that means a streamed response with a generator (Laravel's <code>streamDownload</code>, Django's <code>StreamingHttpResponse</code>, Rails' <code>ActionController::Live</code> or a plain enumerator body). <a href=\"/library/streaming-csv-export-web-app\">Streaming a CSV export from a web app</a> goes through the headers and the buffering traps.</p>\n<p>Streaming solves memory. It does not solve time: a request that runs for ninety seconds may hit a proxy timeout, hold a database connection and tie up a worker. For large exports, hand the work to a background job that writes the file and sends the user a link, as in <a href=\"/library/background-jobs-large-csv-exports\">background jobs for large CSV exports</a>. The rule I use: a synchronous download for results the user can see in a few seconds, and a job beyond that, with the threshold set from measurement.</p>\n<h2>Chunk by key, not by offset</h2>\n<p>Whether streamed or in a job, a large export reads the table in pieces. The obvious way is <code>LIMIT 10000 OFFSET n</code>, advancing <code>n</code> by 10,000. It gets slower as <code>n</code> grows, because the database has to walk past <code>n</code> rows to find where to start. The alternative is keyset pagination: remember the last key you saw and ask for <code>WHERE id &gt; last ORDER BY id LIMIT 10000</code>. On a million-row SQLite table, in chunks of 10,000:</p>\n<pre><code class=\"language-text\">offset: total 0.75s, first chunk 3.9 ms, last chunk 11.8 ms\nkeyset: total 0.28s, first chunk 3.2 ms, last chunk 2.7 ms</code></pre>\n<p>The offset version took 2.7 times as long overall, and its last chunk cost three times its first. SQLite is a gentle case, since it walks an index cheaply, and in MySQL or PostgreSQL with a sort that is not covered by an index, the penalty is larger. Keyset stays flat. It needs a unique, ordered key, and a result sorted by something else (a name, a date) needs a composite key, such as <code>(date, id)</code>. It also has a consistency benefit: rows added during a long export do not shift the pages, so nothing is read twice or skipped.</p>\n<h2>The contract: names, types and nulls</h2>\n<p>An export and an import are two halves of a contract, and the contract is in the details. The header row is the interface. Use names a user can recognize, not column names from your schema, and accept variants on import: <code>E-mail</code>, <code>Email address</code> and <code>email_address</code> should all map to the same field. A header-mapping function in PHP, run on three real-looking headers, did it:</p>\n<pre><code class=\"language-text\">[\"﻿E-mail\",\"Full  Name\",\"Tier\",\"Number of Seats\"]  -&gt; mapped {\"email\":0,\"name\":1,\"plan\":2,\"seats\":3}; missing []; unknown []\n[\"email_address\",\"contact\",\"plan\",\"seats\",\"notes\"]   -&gt; mapped {\"email\":0,\"name\":1,\"plan\":2,\"seats\":3}; missing []; unknown [notes]\n[\"Email\",\"Name\",\"Plan\"]                              -&gt; mapped {\"email\":0,\"name\":1,\"plan\":2}; missing [seats]; unknown []</code></pre>\n<p>The first header has a byte order mark and a double space, and still mapped. The second has an extra column the importer does not know and reported it as unknown instead of failing. The third is missing a required column and said which. <a href=\"/library/csv-choosing-column-names-users-recognize\">Choosing column names users recognize</a> covers the naming rules.</p>\n<p>Types need decisions that a spreadsheet user cannot see. Dates in ISO 8601, so that a file opened in a European Excel and saved again does not flip day and month (<a href=\"/library/handling-time-zones-csv-timestamps\">handling time zones in CSV timestamps</a>). Booleans as <code>true</code> and <code>false</code>, or <code>1</code> and <code>0</code>, one convention, documented. Money as a decimal with a point and no symbol. Identifiers as text, so that a zip code keeps its zero. And nulls: decide what an empty cell means, and what an empty string means, and whether the two differ.</p>\n<h2>Round trips, and the lie of identical files</h2>\n<p>A well-designed export can be fed back into the import and produce the same data, so that a user can export, edit in a spreadsheet and re-import. Test that property, and test it on the data. I exported a table in which one note was NULL and another was an empty string, imported the file into a second table and exported again. The two exports were byte-for-byte identical:</p>\n<pre><code class=\"language-text\">second export identical to first: true\nrows that differ after the round trip: 1\n  id 1  note before NULL         after ''\n  id 2  note before ''           after ''</code></pre>\n<p>The files matched and the data did not. The CSV has no way to tell a NULL from an empty string, since <code>fputcsv</code> writes both as nothing, so the import chose empty string and the NULL was gone. Setting the import to read an empty cell as NULL fixed row 1 and broke row 2, where an empty string became NULL. Neither is wrong, and a decision is required: say what an empty cell means in the format's documentation, and if the distinction matters, represent it in the file, with a marker for NULL or by quoting the empty string. A round-trip test that compares files will pass while the data drifts, so compare the tables. <a href=\"/library/csv-round-tripping-exports-back-imports\">Round tripping exports back into imports</a> goes through the other pitfalls.</p>\n<h2>What happens when the user uploads twice</h2>\n<p>Users double-click, refresh the page, upload the same file again to be safe and send the corrected file on top of the wrong one. An import feature that has no answer to \"this file again\" creates duplicates, and the cleanup is a support ticket. Three layers of protection work together. The first is a record of each import: a row in an imports table with the user, the file name, a content hash, the status (previewed, committed, failed), the counts and the times. A commit button that reads \"commit import 41\" instead of \"commit this file\" cannot be pressed twice, because the second press finds the import already committed. The second is a business key on the rows, so a repeat updates what is there and does not insert again, which is what the <code>updated</code> count in the dry run shows: two of the six rows matched existing customers by email. The third is the content hash, which lets you say \"you uploaded this exact file ten minutes ago\" before doing any work, with a button to proceed anyway for the case where it was deliberate (<a href=\"/library/idempotent-csv-imports-safe-run\">idempotent CSV imports that are safe to run twice</a> goes through the designs).</p>\n<p>A related decision is what an import does to rows that are in the application but not in the file. Most features should leave them alone: the file is a set of changes, not a replacement of the whole table. A \"replace everything\" mode is useful and dangerous, and should be a separate, named option with a confirmation that states how many rows will be removed.</p>\n<h2>Big imports: transactions, batches and progress</h2>\n<p>The transaction in the demo wraps six rows. For six thousand, it still works, and for six million it holds locks and a long-lived transaction that other users will feel. The common compromise is to validate the whole file first, without writing, and then commit in batches of a few thousand rows, each its own transaction, in a background job. Batches change the failure semantics: if the job dies at row 3 million, the first 2.9 million are committed. Decide whether that is acceptable and say so on the screen. For an all-or-nothing import, load into a staging table first, validate there, and move the rows into the real tables in one statement (<a href=\"/library/loading-csv-staging-table-then\">loading a CSV into a staging table, then merging</a>). For an import where partial progress is fine, record which batches completed so that a rerun resumes.</p>\n<p>Progress deserves honest treatment. A file of two hundred thousand rows takes minutes, and a spinner with no end is worse than a bar. Count the rows in the inspect stage, update a progress record after each batch, and let the page poll it. Let the user leave the page and come back, with the import visible in a list. And let them cancel: the job checks a flag between batches. Both are small additions and they stop the import screen from becoming the place where people wait and wonder.</p>\n<h2>Language, locale and the user's spreadsheet</h2>\n<p>Users edit these files in the locale of their machine, and the file records it. A German Excel writes <code>1.234,50</code> and a semicolon, a French one writes dates as <code>04/03/2026</code> meaning the fourth of March, a Japanese one may save in Shift JIS if the user chose the wrong format. Accept what you can: detect the delimiter from the first lines (<a href=\"/library/tell-delimiter-unknown-csv\">telling what delimiter an unknown CSV file uses</a>), accept UTF-8 with or without a byte order mark and offer an encoding choice for the rest. For numbers and dates, the safest rule is to tell the user the format you expect and show it in the template, with ISO dates and a decimal point, then reject ambiguous values with a message that names the format, since guessing a date order is how a quiet error becomes a wrong invoice (<a href=\"/library/bank-csv-dates-two-formats\">a bank CSV with dates in two formats</a> shows what evidence you can use).</p>\n<p>If your interface is translated, the headers of the export should be too, and the importer should accept both the translated and the English names, since a file exported in one language and re-imported by a colleague who uses another is an ordinary event. Keep an internal key for each column, translate only the label, and map on either.</p>\n<h2>Audit trails and undo</h2>\n<p>The most valuable feature after the error report is \"undo this import\". It is cheap if you plan for it: store the import ID on every row the import created or changed, and keep the previous values of the rows it updated. Undo then deletes the created rows and restores the old values of the updated ones, and a user who realizes that the file was the wrong one has a way back that does not involve a support request and a database restore. Even without undo, the import record, with who, when, the file, the counts and the error report, is the audit trail that answers \"who changed these customers\".</p>\n<p>Be careful with what you keep. The uploaded file may hold personal data, and the import record holds a copy of it. Keep it as long as undo or audit needs it and delete it afterward, on a schedule you can state (<a href=\"/library/handling-csv-files-full-customer\">handling CSV files full of customer data under GDPR</a>).</p>\n<h2>Permissions are part of the feature</h2>\n<p>An import writes data, and an export reads it, and both must obey the same rules as the screens. Check three things. Who may import: bulk creation is a power, and many applications reserve it for administrators or a specific role, with the permission checked on the server, never only by hiding the button. Which rows the import may touch: an import that updates by email must be scoped to the importing user's account or tenant, so that a file containing another tenant's email cannot overwrite their record, and the lookup in the update step must include the tenant condition. And which columns: a user who cannot edit a price in the interface must not be able to change it through a column in a file, so the mapping stage should offer only the fields the user may write.</p>\n<p>The export has the mirror-image risk. The CSV of a list view must apply the same filters, the same row-level permissions and the same column masking as the list. An export that is built from a separate query by a separate developer is the classic route around access control: the screen hides the salary column and the CSV includes it. Build both from one definition of what the user may see, and test the export as a restricted user. Rate-limit exports as well, since a complete data dump is a one-click operation that deserves a log entry and a limit.</p>\n<h2>Template files and example rows</h2>\n<p>The cheapest way to reduce errors is to give users a file to start from. A template download with the exact header row, an example row and, if you can, a second sheet of rules (allowed values, formats) prevents mismatched headers, wrong date formats and the use of the wrong column order. Generate it from the same column definitions that drive validation, so they cannot disagree, and version it: when you add a required column, the template changes, and an old file with the old header should get a message that says which template it came from and what is missing.</p>\n<p>Offer the export as the template, too. A user who wants to bulk-update existing records starts from an export of those records, edits the cells and uploads the file. That workflow makes the round trip a first-class feature, which is why the round-trip tests matter: if exporting and re-importing without edits changes anything, the feature teaches users not to trust it.</p>\n<h2>Measuring the feature</h2>\n<p>Once it is live, watch it. The numbers worth tracking are small. The share of imports that commit on the first attempt, which says how well the screen and the template guide people. The most common error codes, which tell you where the instructions are unclear or where a validation rule is stricter than users expect. The size of imports and exports, and their duration, which tell you when to move to batches or a job. And the number of support requests that mention a file, which is the metric the rest exist to reduce. A feature that fails for 30 percent of first attempts with the same error is a feature with a defect in its explanation, and the cheapest fix is a better message or a template, not more validation.</p>\n<h2>Safety</h2>\n<p>Import and export are attack surfaces, and the checks are the ones in <a href=\"/library/csv-security-risks-catch-teams\">CSV security risks that catch teams out</a>. On export, any text a user typed that starts with <code>=</code>, <code>+</code>, <code>-</code> or <code>@</code> must be neutralized for spreadsheets (<a href=\"/library/escaping-formula-characters-csv-export\">escaping formula characters in a CSV export</a>), because the person who opens the file is not the person who typed the value. On import, enforce limits on size, rows, columns and field length before you parse (<a href=\"/library/limiting-file-size-row-count\">limiting file size and row count for CSV uploads</a>), and process the file under a role that can insert only what the user may create. Permissions and tenancy belong in the commit stage: an import into one customer's account must never touch another's rows, and an export must apply the same row-level filters as the screen it came from, or it becomes the way around them. Record who imported what and when, since a bulk import is the easiest way to change a thousand records at once and the first thing someone asks about afterward.</p>\n<h2>Working with Excel users</h2>\n<p>Most people who touch these files use Excel, and a feature that ignores that gets support tickets. Offer an Excel-friendly export, UTF-8 with a byte order mark so accents survive a double-click, and say so on the button (<a href=\"/library/excel-csv-utf8-csv-comma\">Excel CSV UTF-8 versus CSV comma delimited</a>). Accept both comma and semicolon delimiters on import, since a European Excel writes semicolons (<a href=\"/library/opening-semicolon-separated-csv-excel\">opening a semicolon separated CSV in Excel</a>). Offer a template download with the exact headers and an example row, which prevents more errors than any validation. And assume that identifiers will be mangled if a person edits the file in a spreadsheet: leading zeros and long numbers are the usual casualties (<a href=\"/library/opening-csv-files-excel\">opening CSV files in Excel and Sheets without damage</a>), so validate them on import and say what went wrong.</p>\n<h2>Testing the feature</h2>\n<p>The tests that earn their keep are few. A fixture of awkward files: with a byte order mark, with semicolons, with quoted newlines, with every error type, empty, header only, and one that is too large. A dry-run test that asserts the table is unchanged afterward. A commit test that asserts the counts. A round-trip test that compares tables. An error-report test that asserts the line numbers refer to the physical lines of the uploaded file. And a large-file test that exports a million rows and asserts that memory stays below a figure, since a streaming export that someone later turns into an array will pass every small test and fail in production.</p>\n<h2>When CSV is the wrong interface for the job</h2>\n<p>CSV import suits bulk loading of records people already have in a spreadsheet and occasional bulk edits. It is the wrong interface when the data comes from another system on a schedule: an API or a managed connector gives you typed fields, authentication, retries and no dialect problems. It is the wrong interface for editing a few records, where a form is better, and for data with relationships, orders with line items, where a flat file cannot express the structure without conventions that are fragile. And it is the wrong export when the consumer is a program: JSON or an API preserves types and nesting, and a CSV makes the consumer guess.</p>\n<p>Where it is right, build it as described: staged, previewed, streamed, tested on round trips and defended. The framework-specific tools, queues, streamed responses and validators, differ, and the shape does not. The format is defined in <a href=\"https://www.rfc-editor.org/rfc/rfc4180\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 4180</a>, the PHP functions in the <a href=\"https://www.php.net/manual/en/function.fgetcsv.php\" target=\"_blank\" rel=\"noopener noreferrer\"><code>fgetcsv</code> documentation</a>, and the upload controls in OWASP's <a href=\"https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">file upload cheat sheet</a>.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/bank-csv-dates-two-formats","url":"https://datahacks.dev/library/bank-csv-dates-two-formats","title":"A Bank CSV With Dates in Two Formats and How to Fix It","summary":"A statement mixes 2026-03-04 and 05/03/2026 in one column. Classify each row, prove the slash order from the data, convert, and check the dates are in order.","content_html":"<p>A bank statement arrives as a CSV with a <code>Date</code> column, and half the values look like <code>2026-03-04</code> while the other half look like <code>05/03/2026</code>. Someone's tool wrote both, maybe because the file was assembled from two exports, or a person typed some rows into a sheet, or the bank changed formats mid-month. Reading it with one format fails on the other half, and letting a library guess will pick a style per file or per row and not tell you which. The way through is to treat the column as two sub-problems: recognize the styles, then find proof for the order of the ambiguous one. This is the first of the problems in <a href=\"/library/worked-csv-problems-real-mailboxes\">worked CSV problems and the patterns behind them</a>, and the general case of dates is in <a href=\"/library/fixing-mixed-date-formats-csv\">fixing mixed date formats in a CSV</a>.</p>\n<h2>The file</h2>\n<p>Six transactions. Three dates are ISO, three are slashes:</p>\n<pre data-filename=\"bank_dates.csv\"><code class=\"language-csv\">Date,Description,Amount\n2026-03-04,CARD PAYMENT ACME,-120.50\n05/03/2026,TRANSFER BETA,-89.90\n2026-03-06,SALARY,2500.00\n13/03/2026,CARD PAYMENT CY,-15.00\n2026-03-14,REFUND,45.00\n03/04/2026,CARD PAYMENT DELTA,-200.00</code></pre>\n<p>Read it as all text first. The profile of the shapes, with each digit replaced by a 9, comes from one query:</p>\n<pre><code class=\"language-sql\">SELECT regexp_replace(\"Date\", '[0-9]', '9', 'g') AS shape, count(*) AS n, min(\"Date\") AS example\nFROM read_csv('bank_dates.csv', all_varchar=true) GROUP BY 1 ORDER BY n DESC;</code></pre>\n<pre><code class=\"language-text\">shape       n  example\n9999-99-99  3  2026-03-04\n99/99/9999  3  03/04/2026</code></pre>\n<p>The ISO shape is unambiguous: year first, then month, then day. The slash shape is the question. <code>05/03/2026</code> is the 5th of March in a day-first style and the 3rd of May in a month-first one. A parser with a locale default will choose one for every slash date, and if it chooses wrongly, the error is silent: both readings are valid dates.</p>\n<h2>Find proof inside the file</h2>\n<p>The statement itself holds the answer. A month cannot be above 12, so any slash date whose first part is 13 or more can only be day-first, and any whose second part is above 12 can only be month-first. Parse every slash date both ways and count the failures:</p>\n<pre><code class=\"language-sql\">SELECT n, \"Date\",\n       try_strptime(\"Date\", '%d/%m/%Y')::DATE AS as_dmy,\n       try_strptime(\"Date\", '%m/%d/%Y')::DATE AS as_mdy\nFROM raw WHERE \"Date\" ~ '^\\d{2}/\\d{2}/\\d{4}$';</code></pre>\n<pre><code class=\"language-text\">n  Date        as_dmy      as_mdy\n2  05/03/2026  2026-03-05  2026-05-03\n4  13/03/2026  2026-03-13  NULL\n6  03/04/2026  2026-04-03  2026-03-04</code></pre>\n<p>Row 4 parses only as day-first: month-first gives NULL, since there is no month 13. That one row is the proof, and a summary query says it cleanly: the number of slash dates that fail <code>%d/%m/%Y</code> was 0, and the number that fail <code>%m/%d/%Y</code> was 1. If the styles are consistent within the file, and files are consistent far more often than not, every slash date is day-first. Row 2 is the 5th of March and row 6 is the 3rd of April.</p>\n<p>That inference rests on an assumption, that the writer used one convention for all slash dates, and a second, independent check is cheap: the order of the statement.</p>\n<h2>Convert, then check the order</h2>\n<p>With the order established, the conversion tries each format in turn. <code>try_strptime</code> returns NULL on a mismatch instead of raising an error, so <code>coalesce</code> picks whichever format works:</p>\n<pre><code class=\"language-sql\">SELECT n, coalesce(try_strptime(\"Date\", '%Y-%m-%d'), try_strptime(\"Date\", '%d/%m/%Y'))::DATE AS date,\n       \"Description\" AS description, \"Amount\"::DECIMAL(10,2) AS amount\nFROM raw ORDER BY n;</code></pre>\n<pre><code class=\"language-text\">n  date        description        amount\n1  2026-03-04  CARD PAYMENT ACME  -120.50\n2  2026-03-05  TRANSFER BETA       -89.90\n3  2026-03-06  SALARY             2500.00\n4  2026-03-13  CARD PAYMENT CY     -15.00\n5  2026-03-14  REFUND               45.00\n6  2026-04-03  CARD PAYMENT DELTA -200.00</code></pre>\n<p>Read the dates down the column: 4, 5, 6, 13, 14 March, then 3 April. A bank statement is in date order, and these are. That is a second proof, and a strong one for row 6: read as month-first, <code>03/04/2026</code> would be 4 March and sit out of order between 14 March and the end of the file. A check in SQL asserts it, comparing each date with the one before:</p>\n<pre><code class=\"language-sql\">SELECT count(*) FROM (SELECT date &lt; lag(date) OVER (ORDER BY n) AS back FROM clean) WHERE back;</code></pre>\n<p>It returned 0. Put that check into the script as a hard failure, so that a new statement where it fails is stopped and not silently loaded. Also check that no date is NULL after the conversion, which would mean a third style the script does not know, such as <code>4 Mar 2026</code> or <code>04.03.26</code>.</p>\n<h2>What about the month-first reading of row 2</h2>\n<p>Row 2 deserves a second thought, since <code>05/03/2026</code> is valid both ways and nothing in the row decides. It is decided by the rest of the file. Under the day-first rule it is 5 March, between 4 and 6 March, in order. Under month-first it would be 3 May, and the statement would jump forward two months and back. The ordering check settles it. This is the general technique for ambiguous dates: use any constraint the data offers, such as monotonic order, a known range of dates (a statement for March should not hold dates in May) or another column that carries the date unambiguously, such as a posting timestamp. Constraints that agree are evidence, and a reading that violates one is probably wrong.</p>\n<p>Be aware that an ordering test works only when the file is ordered. A file sorted by amount or by description gives no information, and you must not assume order without checking it on the unambiguous rows first: sort the ISO rows by position and confirm they are non-decreasing, and if they are, the assumption holds for the file.</p>\n<h2>When the file gives no evidence about the date order</h2>\n<p>Sometimes there is no proving row. A statement for the first week of a month has slash dates like <code>03/04/2026</code> and <code>05/04/2026</code>, all with a first and second part below 13, and the order cannot be inferred from the values. If the file is also not in date order, there is nothing left. The honest options are to ask the source (\"is this day-first?\"), to take the convention from the system that produced it (a bank in a day-first country writes day-first), or to reject the file with a message that names the ambiguity. Do not choose by the locale of the machine that happens to run the script. That is how a statement is read correctly on one laptop and wrongly in a pipeline on another.</p>\n<p>Also decide what to do when a file mixes conventions within the slash style, for instance when two exports were concatenated, one day-first and one month-first. The proof then fails in the other direction: some rows can only be day-first and others only month-first. A file that contains both <code>13/03/2026</code> and <code>03/14/2026</code> is inconsistent, and the right response is to stop, since no single rule reads it. If you can tell which rows came from which export, for example by a column or by their position, convert each block with its own format.</p>\n<p>The cleanest fix is upstream: ask the bank or the tool for ISO 8601 dates, which have one reading everywhere. In the meantime, the profile query, the two-way parse and the order check turn a guess into a derivation. The functions are documented in DuckDB's <a href=\"https://duckdb.org/docs/2.0/sql/functions/dateformat\" target=\"_blank\" rel=\"noopener noreferrer\">date format reference</a>, and the surrounding approach in <a href=\"/library/handling-time-zones-csv-timestamps\">handling time zones in CSV timestamps</a> and <a href=\"/library/reconciling-bank-statement-csv-ledger\">reconciling a bank statement CSV with your ledger</a>, which uses the clean statement as input. The ISO format itself is defined in <a href=\"https://www.w3.org/TR/NOTE-datetime\" target=\"_blank\" rel=\"noopener noreferrer\">ISO 8601 as summarized by the W3C</a>.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/csv-ecommerce-export-commas-inside-product","url":"https://datahacks.dev/library/csv-ecommerce-export-commas-inside-product","title":"An Ecommerce Export With Commas Inside Product Names","summary":"A product export writes names with commas and no quotes, so rows have 6 fields, not 4. Rejoin the middle fields from the good ends and verify every row.","content_html":"<p>A shop exports its catalog to CSV, and the product names contain commas: <code>Cable, USB-C, 2 m</code>, <code>Stand, aluminium, adjustable</code>. The export did not quote them. A line meant to have four fields now has six, every column after the name is shifted, and the loader either rejects the line or loads the price into the stock column. This is the unquoted-delimiter problem, one of the commonest damage patterns, and it is fixable when the damaged column is the only free-text one. This article diagnoses it, repairs it with a short script and shows where the repair stops being safe. It is one of the problems in <a href=\"/library/worked-csv-problems-real-mailboxes\">worked CSV problems and the patterns behind them</a>, and the quoting rules it breaks are explained in <a href=\"/library/csv-quoting-works\">how CSV quoting works</a>.</p>\n<h2>Diagnose with field counts</h2>\n<p>Here is the file. Look at lines 2 and 4:</p>\n<pre data-filename=\"products.csv\"><code class=\"language-csv\">sku,name,price,stock\nA100,Cable, USB-C, 2 m,9.90,120\nA101,Mouse,14.50,35\nA102,Stand, aluminium, adjustable,39.00,8\nA103,\"Hub, 4 port\",24.00,60</code></pre>\n<p>The header has four fields. The first thing to check is how many each line has, through a CSV parser, not by counting commas yourself:</p>\n<pre><code class=\"language-python\">import csv\nprint([len(r) for r in csv.reader(open(\"products.csv\"))])</code></pre>\n<pre><code class=\"language-text\">[4, 6, 4, 6, 4]</code></pre>\n<p>The header, <code>A101</code> and the quoted <code>A103</code> have four fields. <code>A100</code> has six, and so does <code>A102</code>: two and two extra commas. The extra fields equal the extra commas in the name, which is what unquoted commas look like. Line 5 shows the file is not uniformly broken: the exporter quoted <code>\"Hub, 4 port\"</code> correctly, perhaps because that name came through a different code path or a newer version. A file that mixes quoted and unquoted commas is common, and it means a fix that assumes every comma is unquoted would be wrong, so the script has to handle the already-quoted rows without damaging them.</p>\n<h2>The rule: anchor on the ends</h2>\n<p>The structure is known even though the commas are ambiguous. The first field is a SKU, a short code with no commas. The last two fields are a price and a stock count, numbers with no commas. Everything between them is the name, however many pieces it was split into. That rule is stated in one sentence, and it is the specification of the repair: take <code>row[0]</code> as the SKU, <code>row[-2]</code> and <code>row[-1]</code> as price and stock, and join <code>row[1:-2]</code> with commas as the name.</p>\n<pre data-filename=\"fix_commas.py\"><code class=\"language-python\">import csv, sys\n\nEXPECTED = 4   # sku, name, price, stock\nwith open(sys.argv[1], newline=\"\", encoding=\"utf-8\") as f, open(sys.argv[2], \"w\", newline=\"\", encoding=\"utf-8\") as out:\n    reader = csv.reader(f)\n    writer = csv.writer(out)\n    header = next(reader)\n    writer.writerow(header)\n    for line_no, row in enumerate(reader, start=2):\n        extra = len(row) - EXPECTED\n        if extra &lt; 0:\n            sys.exit(f\"line {line_no}: only {len(row)} fields\")\n        # The first field and the last two are well behaved; everything between belongs to the name.\n        sku, middle, price, stock = row[0], row[1:len(row) - 2], row[-2], row[-1]\n        name = \",\".join(part for part in middle).replace(\" ,\", \",\")\n        writer.writerow([sku, name, price, stock])\n        if extra:\n            print(f\"line {line_no}: rejoined {extra + 1} pieces into {name!r}\")</code></pre>\n<pre><code class=\"language-text\">line 2: rejoined 3 pieces into 'Cable, USB-C, 2 m'\nline 4: rejoined 3 pieces into 'Stand, aluminium, adjustable'</code></pre>\n<p>The script refuses a line with fewer than the expected fields, which would be a different kind of damage, and prints every repaired line with the name it produced, so a person can read the result. It writes through <code>csv.writer</code>, which adds the quotes the exporter should have added. The output file:</p>\n<pre><code class=\"language-csv\">sku,name,price,stock\nA100,\"Cable, USB-C, 2 m\",9.90,120\nA101,Mouse,14.50,35\nA102,\"Stand, aluminium, adjustable\",39.00,8\nA103,\"Hub, 4 port\",24.00,60</code></pre>\n<p>Every row now has four fields. The already-quoted <code>A103</code> went through unchanged: the parser had read it as four fields, so <code>extra</code> was zero and the name was <code>Hub, 4 port</code> as before. The rows that were wrong now have the names they should.</p>\n<h2>Proof</h2>\n<p>Run the field-count check on the output: all lines should be 4. Then check the values against what you know about each column. The prices should all parse as decimals and the stock as integers, which is a stronger test than the field count, because an off-by-one repair would put a name fragment where a number belongs and the cast would fail. In SQL, <code>TRY_CAST(price AS DECIMAL(10,2)) IS NULL</code> should return no rows. Compare the row count with the input's, and if the shop can tell you the number of products, compare with that. And read a sample of the names: the repaired ones are the ones to eyeball, and the printed lines are the list.</p>\n<p>One detail in the script deserves comment. The line <code>.replace(\" ,\", \",\")</code> normalizes a space before a comma. It was added for names where an exporter had padded; leave out any such cosmetic step unless you know the data needs it, since every extra rule is something you have to justify. The version in the repository should show only the rejoin and say what it assumes.</p>\n<h2>Where the repair stops being safe</h2>\n<p>The anchor rule works only if the commas can appear in exactly one column and the columns around it have a recognizable shape. Three situations break it.</p>\n<p>Two free-text columns: if the file also had a <code>description</code> column with commas, you could not tell where the name ends and the description begins. <code>A100,Cable, USB-C,Short cable, cheap,9.90,120</code> could split four ways, and no program can choose. The only fixes are a convention you can verify (names never contain more than two commas) or a re-export with quotes.</p>\n<p>A free-text column at an end: if the name were the last column, there would be no right-hand anchor, and the number of pieces to join would be unknown. A left anchor of known width only works if there is a fixed count of columns after the name.</p>\n<p>A name that ends in something that looks like the anchor shape: <code>Cable, 9.90 m</code> has a piece that parses as a number, and the right-anchored rule still works because it counts from the right by position and does not look at what the pieces contain. A rule that scans from the right until it finds \"something numeric\" would break here, and positions are safer than patterns.</p>\n<p>For files where the repair is ambiguous, quarantine the rows: write the lines that cannot be repaired confidently to a rejects file with their line numbers and let a person decide (<a href=\"/library/logging-rejected-rows-csv-import\">logging rejected rows from a CSV import for review</a>).</p>\n<h2>Fix it at the source</h2>\n<p>The repair is a patch for an exporter that does not follow <a href=\"https://www.rfc-editor.org/rfc/rfc4180\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 4180</a>, which asks for fields containing commas to be quoted. Tell whoever owns the export, with the line numbers and the field counts, and ask for quoting. Most platforms have a setting or a newer export format that does it correctly, and sometimes the problem is that someone saved the export from a spreadsheet that dropped the quotes. Until then, put the field-count check at the door of your pipeline so the next change, a different delimiter in a product name, a second free-text column, is caught as an error and not loaded as shifted data (<a href=\"/library/checking-row-column-counts-csv\">checking row and column counts in a CSV</a>). The Python module used here is described in the <a href=\"https://docs.python.org/3/library/csv.html\" target=\"_blank\" rel=\"noopener noreferrer\">csv reference</a>.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/csv-crm-export-multi-line-notes","url":"https://datahacks.dev/library/csv-crm-export-multi-line-notes","title":"A CRM Export With Multi Line Notes and How to Parse It","summary":"A CRM export has notes with line breaks inside quoted fields, so line counts and grep mislead. Parse by records, then flatten the breaks or keep them.","content_html":"<p>CRM exports are where multi-line fields live. A salesperson types a note with a few lines, the system exports it faithfully inside quotes, and every tool downstream that thinks in lines now disagrees about how long the file is. <code>wc -l</code> says 7, the importer says 3 customers, a script that processes line by line sees notes broken in half and <code>grep</code> returns half a record. Nothing is wrong with the file: quoted line breaks are valid CSV. What is wrong is the assumption that a line is a record. This article shows how to count and parse such a file correctly and how to flatten it when a tool downstream insists on one line per row. It is one of the problems in <a href=\"/library/worked-csv-problems-real-mailboxes\">worked CSV problems and the patterns behind them</a>, and it relies on the quoting rules in <a href=\"/library/csv-quoting-works\">how CSV quoting works</a>.</p>\n<h2>The file, and the two counts</h2>\n<p>Three customers. The first note runs to three lines, the third has a quoted word inside it:</p>\n<pre data-filename=\"crm.csv\"><code class=\"language-csv\">id,company,owner,note\n1,Acme Ltd,ada,\"Called on Monday.\nWants a quote for 40 seats.\nFollow up Friday.\"\n2,Beta Corp,bob,No answer\n3,\"Cy Foods, Inc\",cy,\"Said \"\"maybe\"\" twice\nasked for pricing\"</code></pre>\n<p>Count it two ways:</p>\n<pre><code class=\"language-bash\">wc -l crm.csv</code></pre>\n<pre><code class=\"language-text\">       7 crm.csv</code></pre>\n<pre><code class=\"language-python\">import csv\nrows = list(csv.reader(open(\"crm.csv\", newline=\"\")))\nprint(\"records:\", len(rows) - 1)</code></pre>\n<pre><code class=\"language-text\">records: 3</code></pre>\n<p>Seven lines, three records, one header. The lines break down as the header (1 line), customer 1 with its three-line note (3 lines), customer 2 (1 line) and customer 3 with a two-line note (2 lines). Any process that counts, samples or splits by line will be wrong by exactly the number of embedded breaks. That includes <code>head -n 100</code>, which can cut a record in half at the end of its output, and <code>split -l</code>, which can cut one in half between two files (<a href=\"/library/splitting-large-csv-smaller-files\">splitting a large CSV into smaller files</a> shows how to cut on record boundaries).</p>\n<h2>Parse by records</h2>\n<p>The CSV parser follows the quotes, so a line break inside quotes is data. In Python, open the file with <code>newline=\"\"</code> so the module receives the line endings unchanged and can tell a break inside a field from one that ends a record:</p>\n<pre><code class=\"language-python\">import csv\nwith open(\"crm.csv\", newline=\"\", encoding=\"utf-8\") as f:\n    for row in csv.DictReader(f):\n        print(row[\"id\"], repr(row[\"note\"]))</code></pre>\n<pre><code class=\"language-text\">1 'Called on Monday.\\nWants a quote for 40 seats.\\nFollow up Friday.'\n2 'No answer'\n3 'Said \"maybe\" twice\\nasked for pricing'</code></pre>\n<p>Each note arrived as one string, with <code>\\n</code> where the breaks were. The doubled quotes in note 3 became single quotes: <code>\"\"maybe\"\"</code> inside a quoted field is the escape for a literal quote, so the value is <code>Said \"maybe\" twice</code>. Leaving <code>newline=\"\"</code> off is the classic mistake: Python's text mode translates line endings on read, and on a file with Windows line endings you may get doubled or lost breaks inside fields.</p>\n<p>DuckDB does the same without any setting, and its <code>read_csv</code> detects the quoting:</p>\n<pre><code class=\"language-sql\">SELECT id, company, replace(note, chr(10), ' | ') AS note,\n       length(note) - length(replace(note, chr(10), '')) AS breaks\nFROM read_csv('crm.csv') ORDER BY id;</code></pre>\n<pre><code class=\"language-text\">id  company         note                                                                  breaks\n1   Acme Ltd        Called on Monday. | Wants a quote for 40 seats. | Follow up Friday.   2\n2   Beta Corp       No answer                                                             0\n3   Cy Foods, Inc   Said \"maybe\" twice | asked for pricing                                1</code></pre>\n<p>Three rows, with the breaks counted per note (2, 0 and 1) and shown as <code> | </code> for display. The count of breaks per row also reconciles the numbers from before: 3 breaks in total, so the physical lines are 4 (the header plus three single-line records) plus 3 breaks, which is 7.</p>\n<h2>Flatten for tools that want one line per row</h2>\n<p>Some destinations cannot take line breaks inside a value: a log shipper, a tool that reads the CSV with <code>split('\\n')</code>, a fixed-format feed. Do not try to fix those by regex on the raw file, since a regex cannot know whether a line break is inside quotes. Parse first, then replace in the parsed value:</p>\n<pre><code class=\"language-python\">import csv, sys\nwith open(\"crm.csv\", newline=\"\", encoding=\"utf-8\") as f, open(\"crm-flat.csv\", \"w\", newline=\"\", encoding=\"utf-8\") as out:\n    reader, writer = csv.reader(f), csv.writer(out)\n    for row in reader:\n        writer.writerow([c.replace(\"\\r\\n\", \" | \").replace(\"\\n\", \" | \").replace(\"\\r\", \" | \") for c in row])</code></pre>\n<p>The separator <code> | </code> keeps the structure visible, and a space would glue sentences together (<code>Monday.Wants</code>). Replace <code>\\r\\n</code> first, then the lone <code>\\n</code> and <code>\\r</code>, so that a Windows break is not turned into two separators. If the exact text matters later, keep the original column and add the flattened copy next to it, rather than replacing it. And tell the people who read the flat version that the pipes are line breaks, because <code> | </code> may also occur in the original text; pick a separator that does not.</p>\n<p>In the other direction, a tool that must receive the breaks, such as a text area, can have them back by reversing the replacement, which only works if the separator is unambiguous.</p>\n<h2>Tools that need care</h2>\n<p>Spreadsheets handle the breaks, with caveats. Excel shows a multi-line cell as one cell with wrapped text if the wrap setting is on, and on a double-click open it may display only the first line. Sorting or filtering is unaffected, and saving writes them back inside quotes (<a href=\"/library/excel-csv-rfc-csv-differences\">Excel CSV versus RFC CSV differences</a>). <code>grep</code> and <code>awk</code> see lines, so a search for \"Friday\" prints the third line of note 1 and not the customer it belongs to; use <code>csvgrep</code> or <code>qsv search</code>, which parse the CSV and print the whole record (<a href=\"/library/working-csv-files-shell\">working with CSV files in the shell</a>). Database loaders handle quoted breaks if told the format is CSV: PostgreSQL's <code>COPY ... CSV</code> does, and its text format escapes the break as <code>\\n</code> instead (<a href=\"/library/postgresql-csv-text-format-copy\">PostgreSQL CSV versus text format in COPY</a>).</p>\n<p>Parallel readers deserve a warning. A loader that splits a large file into byte ranges and parses each chunk independently can start a chunk in the middle of a quoted field and read the rest as a different structure. Systems that do this either need <code>allow_quoted_newlines</code> style settings and give up parallelism or must verify the split, so check the documentation before loading a 20 GB file of notes with a parallel loader.</p>\n<h2>When the quotes are not trustworthy</h2>\n<p>All of this assumes the file's quoting is correct, and exports from some CRM systems are not. Check three things. Every record should have the same number of fields when parsed, which the field-count test finds: a note with a stray unmatched quote swallows the following records, and the count of records comes out low. Look at the record count against what the CRM says it exported. And scan the notes for signs of a broken field: a note that starts with a customer ID and a comma, where the parser merged two records. If the quotes are broken, the problem is the unescaped-quote case, and the repair is a lenient parser setting, shown in <a href=\"/library/csv-log-export-unescaped-quotes\">a log export with unescaped quotes</a>, applied with a count to prove it.</p>\n<p>Finally, consider whether the notes should be in the file at all. Free-text notes are the most sensitive and the messiest column in a CRM, they hold personal data, and an export for analysis rarely needs them (<a href=\"/library/stripping-personal-data-csv\">stripping personal data from a CSV before sharing it</a>). If the analysis needs only a count of notes or their length, compute that at the source and leave the text behind. The DuckDB CSV options are documented in the <a href=\"https://duckdb.org/docs/2.0/data/csv/overview\" target=\"_blank\" rel=\"noopener noreferrer\">CSV overview</a> and the <code>newline</code> argument in Python's <a href=\"https://docs.python.org/3/library/csv.html\" target=\"_blank\" rel=\"noopener noreferrer\">csv documentation</a>.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/designing-csv-import-screen-users","url":"https://datahacks.dev/library/designing-csv-import-screen-users","title":"Designing a CSV Import Screen Users Can Succeed With","summary":"What a CSV import screen needs so users succeed: a template, clear limits, column mapping, a preview, plain-language errors and a count before commit.","content_html":"<p>A CSV import screen has one job: let a person with a spreadsheet get their data into your application without help. The job fails in a predictable way. The person uploads a file, something in it is not what the system expected, and the screen answers with \"Invalid file\" or with a red banner at the top of the page. They do not know which row, which column or what would work, so they try again, then email support. Every one of those cases can be prevented by a design that shows the person what the system understood and what it will do before it does it. This article describes that design, stage by stage, with the details that make the difference. It is the screen-level companion to <a href=\"/library/csv-import-export-features-applications\">CSV import and export features in applications</a>, and the dry-run and error-report mechanics are in <a href=\"/library/previewing-csv-upload\">previewing a CSV upload before committing</a> and <a href=\"/library/csv-reporting-row-level-errors-back\">reporting row-level errors back to the user</a>.</p>\n<h2>Start before the upload</h2>\n<p>The first screen is where most errors are prevented. It has four elements, and each removes a class of mistake.</p>\n<p><strong>A template.</strong> A downloadable file with the exact header row and one or two example rows. Users who start from the template do not misspell headers, reorder columns or guess formats. Generate it from the same definitions that drive validation, so the two can never disagree, and give it a name that includes a version when the columns change.</p>\n<p><strong>The rules, in words.</strong> Next to the upload field: which columns are required, what format dates and numbers use, which values are allowed for the fields with a fixed list, and the limits (file size, maximum rows). State them in the user's terms: \"Plan: free, pro or team\", \"Seats: a whole number, 1 or more\", \"Up to 10,000 rows per file\". A limit the user finds out about after a two-minute upload is a design bug (<a href=\"/library/limiting-file-size-row-count\">limiting file size and row count for CSV uploads</a> covers the numbers).</p>\n<p><strong>What happens to existing records.</strong> A sentence that says whether the import adds new records only, or updates those it finds, and by what key: \"Customers are matched by email. Existing customers are updated; new ones are created.\" Without it, users do not know whether uploading is safe to repeat.</p>\n<p><strong>Format choices.</strong> Accept CSV and, if your users live in spreadsheets, <code>.xlsx</code>, and say which encoding you read. If you only accept UTF-8, say so, and tell Excel users how to save as \"CSV UTF-8\".</p>\n<h2>Show what you understood</h2>\n<p>After upload, do not start work. Show what the system found, and let the person correct it. The inspection step reports the delimiter it detected, the encoding, the number of rows and the header, in a sentence: \"We found 4 columns and 2,310 rows, separated by commas.\" If the detection is wrong, because a semicolon file was read as commas and every row has one column, the user sees it immediately and can choose the delimiter, which is better than a failure message after processing.</p>\n<p>Then the mapping. A table of the file's columns on the left and the application's fields on the right, each with a dropdown, pre-filled by matching the headers (case, spacing, common aliases, as in <a href=\"/library/csv-choosing-column-names-users-recognize\">choosing column names users recognize</a>). The user confirms or corrects, and required fields without a match are flagged in red. A column in the file with no target is shown as \"ignored\", so the user knows it will not be imported, and a user who sees <code>Notes</code> marked ignored when they wanted it imported can fix it now and not after.</p>\n<p>Remember what the mapping is for. A file with headers in French, a file with an extra leading column, a file with the columns in another order all work if the user can map them, and none works if the importer insists on exact names.</p>\n<h2>Preview with real data</h2>\n<p>Show the first 10 to 20 rows as they will be understood, with the type each column was read as: the date shown as a date in the user's format, the number with its thousands separator, the text with leading and trailing spaces trimmed. Misreadings are visible at a glance in this view: a zip code shown as <code>2134</code> where the file has <code>02134</code>, a date showing the wrong month, a column of names shifted into a column of cities by an unquoted comma. Showing parsed values is a far better test for the user than any message.</p>\n<p>Then a summary of what Commit will do. In the test, a six-row file produced:</p>\n<pre><code class=\"language-text\">dry run: {\"created\":1,\"updated\":2,\"rejected\":3}</code></pre>\n<p>On screen that is \"1 new customer, 2 customers will be updated, 3 rows have problems\" with a link to see the problems. This is the most important element on the page. It turns a vague \"import\" into three numbers a person can sanity-check against what they expected: if the file was meant to be 200 new customers and the summary says 200 updates, something is wrong, and the user finds out before anything has changed.</p>\n<h2>Errors in the user's words</h2>\n<p>The rows with problems should be listed in a form the user can act on. In the test, the report read:</p>\n<pre><code class=\"language-text\">line,column,value,problem\n4,plan,enterprise,\"plan must be one of: free, pro, team\"\n4,seats,,\"seats must be a whole number of 1 or more\"\n5,email,not-an-email,\"not a valid email address\"\n6,plan,gold,\"plan must be one of: free, pro, team\"</code></pre>\n<p>Each line has the physical row in the user's file (with the header as line 1, so it matches the row number in their spreadsheet), the column using the user's own header name and not an internal field, the value that failed, and the rule. Two problems on one row, as on line 4, are both shown, so the user is not sent round the loop twice. Messages say what would work: \"must be one of: free, pro, team\" is a fix, and \"invalid value\" is a complaint. Avoid error text that blames (\"you entered an invalid email\"); the file is a dataset, and the message is a description of it.</p>\n<p>For many errors, show a summary first: \"128 rows have an unknown plan\" with five examples, and the full list as a download. A table of 128 individual lines hides the pattern that one cell of a column was misspelled throughout. The downloadable file should contain the original rows plus an <code>error</code> column, so that the user can fix and re-upload only those rows.</p>\n<h2>Let the valid rows through, when you can</h2>\n<p>Some imports must be all-or-nothing, for instance a ledger that has to balance. Most do not, and refusing 2,300 good rows because 7 are bad is a design that makes people avoid the feature. Offer a choice, with the default that suits the data: \"Import the 2,303 valid rows and download the 7 that need fixing\" or \"Cancel and fix the file first\". Make the second the default only if partial import would leave the data inconsistent. If you import partially, make the next step obvious: the download of rejected rows is in the confirmation, not three clicks away.</p>\n<h2>After the commit</h2>\n<p>End with a report, not a toast. The numbers (created, updated, rejected), a link to the rejected rows, a link to the records that changed if the application can filter by import, and an undo if you built one. Keep a list of past imports, with who ran them and when, so the person who comes back tomorrow and wonders what they did can see it. The record also serves support: when someone writes \"my import broke\", the import ID shows the file, the mapping and the errors.</p>\n<h2>Small touches that matter</h2>\n<p>A few details make the difference between usable and merely working. Keep the user's file name on every screen, so they remember which one they are importing. Do not time out silently: for a large file, show progress and let them leave the page. Make the whole flow work with a keyboard and a screen reader, which means real tables for the mapping and the preview, labels on every dropdown and error text that is not conveyed by color alone. Write for non-engineers: \"column\" and \"row\" are fine, \"delimiter\" needs a word of explanation, and \"constraint violation\" is not for users. And test the screen with a real person and a bad file, because the errors you did not think of are the ones that matter.</p>\n<h2>When a single upload form is enough</h2>\n<p>If the import is rare, the file is produced by your own systems and the users are administrators who read the log, a plain form with a file field, a summary and a downloadable error report is enough, and the mapping stage is overhead. If the file always has the same layout because you control the template, skip mapping and validate the header strictly with a message that lists the expected and the found columns. Add stages as the audience widens: more users, more sources, more mistakes. The Mozilla Developer Network documents the browser side of file inputs in its <a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file\" target=\"_blank\" rel=\"noopener noreferrer\">file input guide</a>, and the upload risks to design against are in OWASP's <a href=\"https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">file upload cheat sheet</a>.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/csv-supplier-price-list-merged-header","url":"https://datahacks.dev/library/csv-supplier-price-list-merged-header","title":"A Supplier Price List With Merged Header Rows Fixed","summary":"A price list CSV has title lines, a header on row 4, category names written once per group and a footer. Skip, forward fill, filter and check the item count.","content_html":"<p>Suppliers send price lists as spreadsheets and often as CSV exports of those spreadsheets, and a spreadsheet is a layout, not a table. There is a title on the first row, a validity note on the second, a blank row for air, then the header, then the data, in which each category name is written once on its first row and left blank beneath, as it would be in a merged cell. At the foot, a note about discounts. A person reads it perfectly. A parser sees a file whose first four rows are not data, whose category column is mostly empty and whose last rows are prose. The cleaning is three moves: skip the preamble, fill the blanks downward, and keep only rows that are items. This article does it on a small file in DuckDB and shows how to prove that no product was lost. It is one of the problems in <a href=\"/library/worked-csv-problems-real-mailboxes\">worked CSV problems and the patterns behind them</a>, and a sibling of <a href=\"/library/parsing-multi-line-headers-report\">parsing multi line headers in report style CSV files</a>.</p>\n<h2>The file</h2>\n<pre data-filename=\"price_list.csv\"><code class=\"language-csv\">ACME SUPPLY CO - PRICE LIST Q4,,,\nPrices exclude VAT. Valid until 31 Dec.,,,\n,,,\nCategory,Item,Unit,Price\nCables,USB-C 1 m,each,4.20\n,USB-C 2 m,each,5.80\n,HDMI 2 m,each,7.10\nAdapters,USB-C to HDMI,each,12.90\n,USB-A to USB-C,each,3.40\n,,,\nNotes: bulk discounts on request,,,</code></pre>\n<p>Eleven lines. Lines 1 and 2 are title and terms, line 3 is blank, line 4 is the header, lines 5 to 9 hold five items under two categories, line 10 is blank and line 11 is a note. Every line has four comma-separated fields, because the spreadsheet had four columns, which is why a CSV reader does not complain: the file is perfectly regular as CSV and irregular as a table.</p>\n<p>A naive read with the header on line one makes <code>ACME SUPPLY CO - PRICE LIST Q4</code> the first column name and gives three unnamed columns. The right read skips the preamble, and the rest follows.</p>\n<h2>Skip, number, fill down</h2>\n<p>Skip the first four lines so that data starts at line 5, read with no header and explicit column names, and number the rows, since the fill-down needs a stable order:</p>\n<pre><code class=\"language-sql\">WITH raw AS (\n  SELECT row_number() OVER () AS n, *\n  FROM read_csv('price_list.csv', header=false, skip=4,\n                columns={'category':'VARCHAR','item':'VARCHAR','unit':'VARCHAR','price':'VARCHAR'},\n                null_padding=true)\n)\nSELECT n,\n       last_value(nullif(category, '') IGNORE NULLS) OVER (ORDER BY n) AS category,\n       item, unit, price::DECIMAL(10,2) AS price\nFROM raw\nWHERE item &lt;&gt; '' AND item IS NOT NULL AND TRY_CAST(price AS DECIMAL(10,2)) IS NOT NULL\nORDER BY n;</code></pre>\n<pre><code class=\"language-text\">n  category  item             unit  price\n1  Cables    USB-C 1 m        each   4.20\n2  Cables    USB-C 2 m        each   5.80\n3  Cables    HDMI 2 m         each   7.10\n4  Adapters  USB-C to HDMI    each  12.90\n5  Adapters  USB-A to USB-C   each   3.40</code></pre>\n<p>The <code>last_value(... IGNORE NULLS)</code> window carries the most recent non-NULL category down the rows: <code>nullif(category, '')</code> turns the empty strings of the blank cells into NULL, and <code>IGNORE NULLS</code> tells the window to skip them and return the last real value. Row 2 has no category of its own and gets <code>Cables</code> from row 1. Row 4 starts <code>Adapters</code>, and row 5 inherits it. The <code>ORDER BY n</code> is the contract: the fill is only correct if the rows are in file order, which is why they were numbered at the start. Without a number, a window with no order has no meaning, and a query that reorders rows would put categories on the wrong items.</p>\n<p>The <code>WHERE</code> clause is the filter that drops what is not an item. It requires a non-empty item and a price that parses as a decimal. The blank line after the data has an empty item and is dropped. The note line, <code>Notes: bulk discounts on request</code>, has text in the first column, but it is the category column, and its item and price are empty, so it is dropped too. The filter does not name the footer. It describes what an item is, and anything else falls out, which is why the same query still works when the next issue has a different footer.</p>\n<h2>The proof</h2>\n<p>Three checks make the result trustworthy. The item count should equal the number you see in the source sheet. Here it is 5, and a person counting the rows in the original agrees. The category count should match as well: two categories, <code>Cables</code> and <code>Adapters</code>, each with the right number of items (3 and 2). And every kept row must have a parseable price, which the filter guarantees, so a different check is needed to catch the opposite error, a real item dropped because its price was written oddly. Count the rows that were excluded and look at them:</p>\n<pre><code class=\"language-sql\">SELECT n, category, item, price FROM raw\nWHERE NOT (item &lt;&gt; '' AND item IS NOT NULL AND TRY_CAST(price AS DECIMAL(10,2)) IS NOT NULL);</code></pre>\n<p>On the sample this returns the blank line and the note line, nothing else. If a row with an item and an odd price, say <code>POA</code> or <code>4,20</code> with a decimal comma, appears here, it is a product the filter dropped, and you want to know. The excluded-rows query is the part people skip, and it is the one that finds the missing product.</p>\n<h2>When the preamble varies</h2>\n<p>The <code>skip=4</code> is fragile. If the supplier adds a line to the terms next quarter, the header moves to line 5 and the query silently reads the header as an item (and drops it, since its price does not parse) and shifts nothing else, so it may look fine. Do not hard-code the skip. Find the header by its content: read the file as one text column, locate the first line that starts with <code>Category,</code>, and use that line number:</p>\n<pre><code class=\"language-sql\">SELECT min(n) AS header_line FROM (\n  SELECT row_number() OVER () AS n, line\n  FROM read_csv('price_list.csv', header=false, columns={'line':'VARCHAR'}, delim='\\x01', quote='', escape='')\n) WHERE line LIKE 'Category,%';</code></pre>\n<p>Fail with a clear message if there is no such line, and pass the number into the <code>skip</code> option of the main read. A header that moves is acceptable, and a header that cannot be found stops the load, which is what you want.</p>\n<p>Also decide what to do about the other regular changes: a new column added to the right (extra empty fields at the end of the title lines, and a new header name), a unit column that stops being filled, a second sheet exported as a second block below the first. Each is a reason the script should compare the header it found with the one it expects (<a href=\"/library/handling-csv-provider-who-changes\">handling a CSV provider who changes columns without warning</a>).</p>\n<h2>Merged cells in other places</h2>\n<p>The category column is the usual merged cell. The same technique handles others: a supplier name in the first column that applies to the rows below, a date written once per day, a region on the first row of each block. The rule is the same, fill down with <code>IGNORE NULLS</code>, over rows in file order. Merged cells across columns, a header cell that spans three columns, need a fill to the right, which is a different problem and is covered in <a href=\"/library/parsing-multi-line-headers-report\">parsing multi line headers in report style CSV files</a>.</p>\n<p>Be careful about the case where a blank is meaningful. If a category cell is blank because the item belongs to no category, and not because it was merged, forward fill would assign it to the wrong group. The test is whether the first data row has a category: a blank in the first row means the layout is different from what you assumed, and the script should stop.</p>\n<h2>When the layout changes between issues</h2>\n<p>A supplier who changes the layout every issue is a supplier whose price list should come in a different form. Ask for a flat export, a single header and one item per row with the category in every row, or a data feed. If they cannot, keep the script small, keep a sample of each layout beside it as a test and make it fail on anything unfamiliar. A price list drives money decisions, and a parsing error that drops a product or shifts a price is expensive in a way that a failed job is not. The window function behind the fill is documented in DuckDB's <a href=\"https://duckdb.org/docs/2.0/sql/functions/window_functions\" target=\"_blank\" rel=\"noopener noreferrer\">window function reference</a>, and the reader options in its <a href=\"https://duckdb.org/docs/2.0/data/csv/overview\" target=\"_blank\" rel=\"noopener noreferrer\">CSV overview</a>.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/csv-log-export-unescaped-quotes","url":"https://datahacks.dev/library/csv-log-export-unescaped-quotes","title":"A Log Export With Unescaped Quotes and How to Read It","summary":"A log export has quotes inside quoted fields without doubling them. See what Python and DuckDB return, which setting reads them right and how to prove it.","content_html":"<p>A web server or an application writes its log as CSV, and one of the columns is a request path or a user message, which can contain double quotes. The exporter wraps the field in quotes and does not double the ones inside, so the file has <code>\"/search?q=\"red shoes\" size 9\"</code> where RFC 4180 requires <code>\"/search?q=\"\"red shoes\"\" size 9\"</code>. Standard parsers are written for the rule, and when it is broken they diverge: each reads the line in its own way, and none of the ways is an error. The result is fields that look plausible and are wrong. This article feeds the same four lines to Python and DuckDB, compares what comes back, finds the setting that reads them as intended and shows how to prove the repair. It is one of the problems in <a href=\"/library/worked-csv-problems-real-mailboxes\">worked CSV problems and the patterns behind them</a>, and the rules it breaks are in <a href=\"/library/csv-quoting-works\">how CSV quoting works</a>.</p>\n<h2>The file</h2>\n<pre data-filename=\"weblog.csv\"><code class=\"language-csv\">ts,method,path,status\n2026-10-02T10:00:01,GET,\"/search?q=red shoes\",200\n2026-10-02T10:00:04,GET,\"/search?q=\"red shoes\" size 9\",200\n2026-10-02T10:00:09,POST,\"/cart\",302\n2026-10-02T10:00:12,GET,\"/search?q=5\" nails\",404</code></pre>\n<p>Lines 1 and 3 are fine: quoted fields with no quotes inside. Line 2 has a quoted field whose value contains <code>\"red shoes\"</code>, and the exporter wrote the inner quotes bare. Line 4 has a quote that stands for inches, <code>5\"</code>, also bare. A human reads line 2 as <code>/search?q=\"red shoes\" size 9</code> and line 4 as <code>/search?q=5\" nails</code>.</p>\n<h2>What each parser makes of it</h2>\n<p>Python's <code>csv</code> module with its defaults reads a quote in the middle of a quoted field as the start of an escape, or as a literal, depending on the next character, and for these lines it returns:</p>\n<pre><code class=\"language-text\">['2026-10-02T10:00:01', 'GET', '/search?q=red shoes', '200']\n['2026-10-02T10:00:04', 'GET', '/search?q=red shoes\" size 9\"', '200']\n['2026-10-02T10:00:09', 'POST', '/cart', '302']\n['2026-10-02T10:00:12', 'GET', '/search?q=5 nails\"', '404']</code></pre>\n<p>Each row has four fields, and the damaged ones are wrong: the quote that opened <code>\"red shoes\"</code> has vanished and reappeared after <code>size 9</code>, and the line 4 path lost its inch mark and gained a trailing quote. The field count is right and the content is not, which is the worst kind of error.</p>\n<p>DuckDB's default settings sniff the file and, finding the quoting inconsistent, return the fields with the outer quotes kept as ordinary characters:</p>\n<pre><code class=\"language-text\">('2026-10-02T10:00:01', 'GET', '\"/search?q=red shoes\"', '200')\n('2026-10-02T10:00:04', 'GET', '\"/search?q=\"red shoes\" size 9\"', '200')\n('2026-10-02T10:00:09', 'POST', '\"/cart\"', '302')\n('2026-10-02T10:00:12', 'GET', '\"/search?q=5\" nails\"', '404')</code></pre>\n<p>Now every path, including the good ones, has quotes around it. The sniffer decided that the quote character is not a quote, so line 1 and line 3 are damaged too. That is the sniffer's honest answer to a file that does not follow the rules, and it is a file-wide change of meaning, so it does more harm in this case than Python's mistake, which was confined to the bad lines.</p>\n<h2>The lenient setting</h2>\n<p>DuckDB has a <code>strict_mode</code> option. With <code>strict_mode=false</code>, the reader allows quotes inside quoted fields and closes a field when the quote is followed by a delimiter or the end of the line, which is the rule a human applies to this file:</p>\n<pre><code class=\"language-sql\">SELECT * FROM read_csv('weblog.csv', all_varchar=true, strict_mode=false);</code></pre>\n<pre><code class=\"language-text\">('2026-10-02T10:00:01', 'GET', '/search?q=red shoes', '200')\n('2026-10-02T10:00:04', 'GET', '/search?q=\"red shoes\" size 9', '200')\n('2026-10-02T10:00:09', 'POST', '/cart', '302')\n('2026-10-02T10:00:12', 'GET', '/search?q=5\" nails', '404')</code></pre>\n<p>All four match the intended values: the good lines are unchanged, <code>\"red shoes\"</code> keeps both its quotes inside the path and the inch mark survives. Adding <code>ignore_errors=true</code> on top gave the same four rows, and it would also skip rows the reader cannot parse, which I did not need and which hides damage, so I left it off.</p>\n<p>The rule that worked was that a quote ends a field only when the next character is a comma or a line break. That heuristic is exactly right for this file and it is still a heuristic: a legitimate field containing <code>\", </code> in its text, say a quoted phrase followed by a comma and a space, would be closed early. So the fix is not \"use lenient mode\" in general. It is \"use lenient mode for this exporter and prove that it reads this exporter's output correctly\".</p>\n<h2>The proof</h2>\n<p>Run three checks on the repaired read. Every row must have exactly four fields, which in DuckDB is true by construction of the schema and needs checking in a tool that does not enforce columns. The count of rows must equal the count of lines in the source minus the header, adjusted for embedded breaks, which here is 4. And the broken lines must read as expected, which you check by eye: pull out the rows whose path contains a quote character and look at them.</p>\n<pre><code class=\"language-sql\">SELECT path FROM read_csv('weblog.csv', all_varchar=true, strict_mode=false) WHERE path LIKE '%\"%';</code></pre>\n<pre><code class=\"language-text\">/search?q=\"red shoes\" size 9\n/search?q=5\" nails</code></pre>\n<p>Two rows, both as intended. On a real log with millions of lines, that query might return thousands, and reading a hundred of them tells you whether the heuristic holds. If any of them looks cut off or merged with a neighbor, the heuristic fails for some shape of line you have not thought of.</p>\n<p>A further check catches the dangerous cases: look for fields that end with a comma followed by something that looks like the next column, and for rows where a later column, the status, is not a three-digit number. A mis-split line puts text in a numeric column. A numeric cast on the status column (<code>TRY_CAST(status AS INTEGER)</code>) with a count of failures is a cheap alarm.</p>\n<h2>If you cannot use DuckDB</h2>\n<p>Other parsers have similar escape hatches. Python's <code>csv</code> module accepts <code>strict=True</code> to raise on malformed input instead of guessing, which is better for finding the bad lines and does not repair them, and <code>quoting=csv.QUOTE_NONE</code> with a custom quote character turns quoting off, which breaks fields with delimiters. pandas has <code>on_bad_lines</code> for skipping or warning. A regular expression that implements the \"quote ends before a comma or end of line\" rule is a handful of lines and works on any language, at the cost of being your code to maintain:</p>\n<pre><code class=\"language-python\">import re\nFIELD = re.compile(r'\"((?:[^\"]|\"(?!,|$))*)\"(?:,|$)|([^,]*)(?:,|$)')\n\ndef split_line(line):\n    out, pos = [], 0\n    while pos &lt; len(line):\n        m = FIELD.match(line, pos)\n        out.append(m.group(1) if m.group(1) is not None else m.group(2))\n        pos = m.end()\n    return out</code></pre>\n<p>Run on the four lines, it returned the same values as DuckDB's lenient mode:</p>\n<pre><code class=\"language-text\">['2026-10-02T10:00:04', 'GET', '/search?q=\"red shoes\" size 9', '200']\n['2026-10-02T10:00:12', 'GET', '/search?q=5\" nails', '404']</code></pre>\n<p>That pattern reads a quoted field as a run of characters where a quote is allowed if it is not followed by a comma or the end, which is the same heuristic. Test it on your broken lines, include a line with <code>\", </code> inside a field in the tests, and be ready to find that it does not handle it; I did not try that case. For anything beyond a one-off, prefer the parser option and keep the regex as a cross-check.</p>\n<h2>When no parser setting can recover the field</h2>\n<p>Some damage is not recoverable. If the quoted field contains both an unescaped quote and the delimiter, such as <code>\"/search?q=5\", 6\"</code>, there are two readings and the text itself does not choose. If a log line is truncated in the middle of a quoted field, there is no closing quote to find. If a field contains a newline and an unescaped quote, the record boundary is lost. For these, count the rows that fail the field-count and status checks and send them to a rejects file with their line numbers (<a href=\"/library/logging-rejected-rows-csv-import\">logging rejected rows from a CSV import for review</a>), and tell the owner of the exporter.</p>\n<p>The real fix is at the source: configure or patch the log writer to double quotes inside quoted fields, or switch the format to one that has no such ambiguity, such as tab-separated with escaping or JSON lines. <a href=\"https://www.rfc-editor.org/rfc/rfc4180\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 4180</a> states the rule in one sentence, and DuckDB documents the reader options in its <a href=\"https://duckdb.org/docs/2.0/data/csv/overview\" target=\"_blank\" rel=\"noopener noreferrer\">CSV overview</a> and its page on <a href=\"https://duckdb.org/docs/2.0/data/csv/reading_faulty_csv_files\" target=\"_blank\" rel=\"noopener noreferrer\">reading faulty CSV files</a>.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/csv-government-dataset-footnotes-data","url":"https://datahacks.dev/library/csv-government-dataset-footnotes-data","title":"A Government Dataset With Footnotes in the Data Cells","summary":"Statistical CSVs put flags in value cells: 1,204,300 (p), 5.3 e, and dots for missing. Split number from flag, keep both and check against the stated total.","content_html":"<p>Official statistics are published with care and with symbols. A population figure is <code>1,204,300 (p)</code> because it is provisional. An unemployment rate is <code>5.3 e</code> because it is an estimate. A cell holds <code>..</code> or <code>x</code> because the value is unavailable or was withheld to protect someone's privacy. Each of those marks is information that a statistician wants beside the number, and a CSV has one place to put it, the cell itself. So the file is exactly what a person needs and almost useless to a program: the column that should be numeric holds text, with flags in various positions and several tokens for missing. This article shows how to split the number from its flag, keep both, check the result against a stated total and leave the suppressed cell alone. It is one of the problems in <a href=\"/library/worked-csv-problems-real-mailboxes\">worked CSV problems and the patterns behind them</a>, and it shares a technique with <a href=\"/library/csv-analytics-export-percent-signs\">an analytics export with percent signs and dashes</a>.</p>\n<h2>The file</h2>\n<pre data-filename=\"gov.csv\"><code class=\"language-csv\">region,year,population,unemployment_rate,note\nNorth,2024,\"1,204,300 (p)\",4.1,\nSouth,2024,\"980,550\",5.3 e,\nEast,2024,..,x,suppressed\nWest,2024,\"1,150,200 r\",4.8,revised\nTotal,2024,\"4,100,000 (p)\",4.7 e,</code></pre>\n<p>Four things are going on. The population has thousands separators and, in three rows, a suffix flag: <code>(p)</code>, <code>r</code>. The unemployment rate has a flag too, a bare <code>e</code> after a space. East has <code>..</code> for population and <code>x</code> for the rate, tokens that mean \"no value\" and that a statistical agency would explain in a legend. And there is a <code>Total</code> row mixed with the regions, which is both a trap (summing the column double counts) and an opportunity (it is a check).</p>\n<p>The legend matters. The meaning of each symbol belongs to the publisher, and the file does not carry it. Before you write a rule, find the key in the documentation of the dataset and write it into your code: <code>(p)</code> provisional, <code>e</code> estimated, <code>r</code> revised, <code>..</code> not available, <code>x</code> suppressed. Do not guess.</p>\n<h2>Split the number from the flag</h2>\n<p>The value is a number at the start of the cell and a flag after it, and a regular expression separates them. Digits, commas and a decimal point at the start are the number, and whatever remains after trimming is the flag:</p>\n<pre><code class=\"language-sql\">CREATE TABLE tidy AS\nSELECT region, year::INT AS year,\n  TRY_CAST(replace(regexp_extract(population, '^[0-9,]+', 0), ',', '') AS BIGINT) AS population,\n  nullif(trim(regexp_replace(population, '^[0-9,]+', '')), '') AS population_flag,\n  TRY_CAST(regexp_extract(unemployment_rate, '^[0-9.]+', 0) AS DECIMAL(5,2)) AS unemployment_rate,\n  nullif(trim(regexp_replace(unemployment_rate, '^[0-9.]+', '')), '') AS unemployment_flag\nFROM read_csv('gov.csv', all_varchar=true);</code></pre>\n<pre><code class=\"language-text\">region  year  population  population_flag  unemployment_rate  unemployment_flag\nEast    2024  NULL        ..               NULL               x\nNorth   2024  1204300     (p)              4.10               NULL\nSouth   2024  980550      NULL             5.30               e\nTotal   2024  4100000     (p)              4.70               e\nWest    2024  1150200     r                4.80               NULL</code></pre>\n<p>Read it cell by cell. <code>1,204,300 (p)</code> became 1204300 and the flag <code>(p)</code>. The plain <code>980,550</code> has a number and no flag. <code>5.3 e</code> became 5.30 with flag <code>e</code>. East's <code>..</code> has no leading digits, so the number is NULL, and the flag column holds the token itself, <code>..</code>, which keeps the reason the value is missing: <code>..</code> and <code>x</code> stay visible instead of vanishing into a NULL. That is the point of keeping a flag column. A NULL says nothing, and <code>..</code> says \"not available\" per the legend.</p>\n<p>The regex has to be written with care. <code>'^[0-9,]+'</code> takes a run of digits and commas, and it would also take a lone comma; for a field that might start with a comma, tighten it to <code>'^[0-9][0-9,]*'</code>. The decimal rates use <code>'^[0-9.]+'</code>. Negative values, a leading minus or parentheses, and a currency symbol are not in this file and would each need their own clause, as in <a href=\"/library/csv-analytics-export-percent-signs\">an analytics export with percent signs and dashes</a>. And since a missing flag is NULL, a statement like <code>population_flag = '(p)'</code> is false for a NULL, which is the behavior you want and a trap for a <code>NOT IN</code> filter.</p>\n<h2>Check what the flags are</h2>\n<p>A table of the distinct flags and how many rows carry each is the proof that you know all the symbols in the file:</p>\n<pre><code class=\"language-sql\">SELECT population_flag, count(*) FROM tidy GROUP BY 1 ORDER BY 1;</code></pre>\n<pre><code class=\"language-text\">population_flag  count\n(p)              2\n..               1\nr                1\nNULL             1</code></pre>\n<p>Compare the list with the legend. A flag that is not in the legend, a <code>k</code> or a <code>*</code>, means either the legend is incomplete or the cleaning has a bug, and you want to stop and find out. Put the allowed flags in a table and fail if a row carries something else. For files that arrive regularly, a new flag is an event: the publisher added a category, and the right response is to read what it means.</p>\n<h2>The total as a check</h2>\n<p>The <code>Total</code> row is a gift. A published total lets you test the parts. Sum the regions that have values and compare:</p>\n<pre><code class=\"language-sql\">SELECT sum(population) FILTER (WHERE region &lt;&gt; 'Total') AS sum_regions,\n       max(population) FILTER (WHERE region = 'Total') AS stated_total FROM tidy;</code></pre>\n<pre><code class=\"language-text\">sum_regions  stated_total\n3335050      4100000</code></pre>\n<p>The three regions with values add to 3,335,050, and the stated total is 4,100,000. They do not match, and the difference, 764,950, is a number worth understanding. In this file the explanation is the suppressed East row: the total includes a value that the table does not show. If exactly one cell is missing, the gap is its value. If two are missing, the gap is their sum, and you cannot separate them. If the numbers had matched, the check would have confirmed that the cleaning kept every value. If they differ by something that is not a suppressed cell, a row was dropped, or a flag swallowed a digit, or the total is computed on a different basis (rounded components, a different date). Always exclude the total row from sums, and write the filter, since a column sum that includes it is exactly double.</p>\n<p>There is a caution about what you do with the gap. Statistical agencies suppress cells because publishing them could identify a person or a firm, and a table that gives the total and all but one cell publishes the last one by subtraction. If you derive 764,950 for East and publish it, you may undo the protection that the source applied on purpose. Use the arithmetic as a check, and do not republish a derived value for a cell the source chose to hide. For small counts, this is a matter of privacy and sometimes law (<a href=\"/library/stripping-personal-data-csv\">stripping personal data from a CSV before sharing it</a> covers the neighboring ideas).</p>\n<h2>Keep the flags in the analysis</h2>\n<p>It is tempting to drop the flag columns once the numbers are clean. Resist. A provisional figure may be revised, an estimate has a margin the final figure does not, and a chart that treats them alike overstates how much is known. Keep the flags, show them where they matter (a footnote mark, a lighter bar) and filter on them when you need final data only: <code>WHERE population_flag IS NULL</code> selects the values with no qualifier. When a later release replaces <code>(p)</code> with the final number, compare: a provisional value that moves by more than a tiny amount is itself a finding.</p>\n<h2>When a symbol means something you must not drop</h2>\n<p>Some marks change the meaning of the number. A superscript or letter that says the figure refers to a different population or a different definition, a <code>break in series</code> note, a unit change from thousands to millions halfway down the table: those are not decoration. If the legend says a mark changes the basis, carry it as a column and do not combine rows with different marks in one aggregate. And when the file carries footnotes as separate lines at the bottom (<code>1. Excludes...</code>), read them before you trust the table, because they often hold the definitions. The format gives you none of this, and <a href=\"https://www.rfc-editor.org/rfc/rfc4180\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 4180</a> is silent on it. For the DuckDB functions used, see its <a href=\"https://duckdb.org/docs/2.0/sql/functions/regular_expressions\" target=\"_blank\" rel=\"noopener noreferrer\">regular expression functions</a> and <a href=\"https://duckdb.org/docs/2.0/data/csv/overview\" target=\"_blank\" rel=\"noopener noreferrer\">CSV overview</a>.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/csv-survey-export-hundreds-columns","url":"https://datahacks.dev/library/csv-survey-export-hundreds-columns","title":"A Survey Export With Hundreds of Columns Made Usable","summary":"A survey CSV has one column per question, 300 wide. Unpivot it to long form with DuckDB, drop blanks, name the questions and check the answer count.","content_html":"<p>Survey platforms export one row per respondent and one column per question. With ten questions that is a fine table. With three hundred, it is a spreadsheet nobody can read, a database table that is awkward to query, and a file where adding a question changes the schema. Analysis wants the other shape: one row per answer, with the respondent, the question and the value, so that \"average by question\" is a <code>GROUP BY</code> and \"answers by country\" is a join. Turning wide into long is one of the most useful moves in data wrangling, and in DuckDB it takes a single statement that does not list the columns. This article does it on a 302-column file, shows the proof that no answer was lost and covers the question metadata that makes the result readable. It is one of the problems in <a href=\"/library/worked-csv-problems-real-mailboxes\">worked CSV problems and the patterns behind them</a>.</p>\n<h2>The wide file</h2>\n<p>The first columns identify the respondent, and the rest are the questions. A trimmed view of the header and a row:</p>\n<pre data-filename=\"survey.csv\"><code class=\"language-csv\">respondent,country,Q1,Q2,Q3,Q4,Q5,...,Q299,Q300\nR001,DE,3,,1,5,,...,2,4</code></pre>\n<p>(The <code>...</code> stands for the columns elided here; the file has all 300.) Values are answers on a one-to-five scale, and an empty cell means the question was not asked, was skipped or was not shown because of a branch. DuckDB reports 302 columns for the file, which is what a spreadsheet would show after scrolling for a while.</p>\n<p>Read it as text first, since an empty column of answers can be inferred as a number or a boolean depending on what the sniffer sees, and you will cast on purpose after the reshape:</p>\n<pre><code class=\"language-sql\">CREATE TABLE wide AS SELECT * FROM read_csv('survey.csv', all_varchar=true);</code></pre>\n<h2>Unpivot with a pattern</h2>\n<p><code>UNPIVOT</code> turns columns into rows. The old way lists every column to unpivot, which for 300 columns is a long and fragile statement. DuckDB lets you select the columns with a regular expression through <code>COLUMNS</code>:</p>\n<pre><code class=\"language-sql\">CREATE TABLE long_t AS\nSELECT respondent, country, question, answer::INTEGER AS answer\nFROM (UNPIVOT wide ON COLUMNS('^Q\\d+$') INTO NAME question VALUE answer)\nWHERE answer &lt;&gt; '';</code></pre>\n<p>The pattern <code>^Q\\d+$</code> selects columns named <code>Q</code> followed by digits, which excludes <code>respondent</code> and <code>country</code>. <code>INTO NAME question VALUE answer</code> names the two new columns: the old column name goes into <code>question</code> and the cell goes into <code>answer</code>. The columns not mentioned, the respondent and the country, are kept on every output row. The <code>WHERE</code> clause drops blank cells, and the cast to integer happens after the unpivot, so a non-numeric answer would raise an error that points at a value instead of making the column text.</p>\n<p>A new question in next month's export, <code>Q301</code>, is picked up by the pattern with no change to the statement, which is the main practical gain over listing the columns. The converse is a hazard: a column that matches the pattern but is not a question, such as a <code>Q4_comment</code> free-text field, would not match <code>^Q\\d+$</code> and be left out, and one called <code>Q99</code> that holds a comment would be pulled in and fail the integer cast, which is the behavior you want. Decide the pattern from the platform's naming scheme and check what it selects: <code>SELECT column_name FROM (DESCRIBE wide) WHERE regexp_matches(column_name, '^Q\\d+$')</code> lists them.</p>\n<h2>The proof: count the answers two ways</h2>\n<p>The long table should hold exactly the non-blank question cells of the original. Compute that count in the wide table without unpivoting, and compare:</p>\n<pre><code class=\"language-sql\">SELECT count(*) AS answers, count(DISTINCT respondent) AS people, count(DISTINCT question) AS questions\nFROM long_t;</code></pre>\n<pre><code class=\"language-text\">answers  people  questions\n1057     5       299</code></pre>\n<p>1,057 answers from 5 people across 299 distinct questions. Five respondents by 300 questions is 1,500 cells, and about 70 percent were filled. To verify 1,057 independently, count in the original, for example with a script that reads the file with the <code>csv</code> module and counts non-empty cells in the question columns, or with a SQL expression that sums <code>(Q1 &lt;&gt; '')::INT + (Q2 &lt;&gt; '')::INT ...</code> generated for all 300 columns. The two numbers must agree. If they do not, the pattern missed a column, a cell held whitespace that <code>&lt;&gt; ''</code> did not catch (trim it first), or a header was duplicated.</p>\n<p>The 299 is also a result: one of the 300 questions had no answers from any of the five respondents. In a real survey, that is worth knowing, as a question nobody answered may be a branch no one reached or a broken form.</p>\n<h2>Analysis in long form</h2>\n<p>The reshape turns wide questions into ordinary SQL. Answers per question and the mean for the most-answered ones:</p>\n<pre><code class=\"language-sql\">SELECT question, count(*) AS n, round(avg(answer), 2) AS mean\nFROM long_t GROUP BY question ORDER BY n DESC, question LIMIT 3;</code></pre>\n<pre><code class=\"language-text\">question  n  mean\nQ102      5  3.0\nQ105      5  1.8\nQ106      5  1.8</code></pre>\n<p>And by group, joining nothing: the respondent's country travels with every answer, so the breakdown is a group by:</p>\n<pre><code class=\"language-sql\">SELECT country, round(avg(answer), 2) AS mean_answer, count(*) AS answers\nFROM long_t GROUP BY country ORDER BY country;</code></pre>\n<pre><code class=\"language-text\">country  mean_answer  answers\nDE       3.10         213\nFR       2.89         228\nGB       3.00         616</code></pre>\n<p>Those figures are from random data and mean nothing. What matters is the form: one <code>GROUP BY</code>, no column names in the query. Note the counts, since GB has 616 answers because three respondents were in GB. A mean over 213 answers from one person is not 213 independent observations. If you want to compare countries, the unit is the respondent, so average each respondent first and then compare the respondents' averages, or the analysis overstates its certainty (<a href=\"/library/comparing-two-time-periods-csv\">comparing two time periods in a CSV</a> makes the same point about bases).</p>\n<h2>Questions need names</h2>\n<p><code>Q102</code> means nothing to a reader. Keep the question text, the scale and the group in a small table, usually exported from the survey tool as a separate file or as the second header row of the export:</p>\n<pre><code class=\"language-csv\">question,text,scale,topic\nQ102,How satisfied are you with delivery?,1-5,delivery\nQ105,Would you recommend us?,1-5,loyalty</code></pre>\n<p>Join it to the long table, and every query gains words. A second header row of question text in the export itself, common in survey tools, is the multi-line header problem, and <code>skip</code> plus a separate read of that row gives the text (<a href=\"/library/parsing-multi-line-headers-report\">parsing multi line headers in report style CSV files</a>). Also separate scale types: a 1-to-5 rating, a yes/no, a multiple-choice code and a free-text answer cannot share one <code>answer</code> column without casting, so either keep <code>answer</code> as text and cast per question type using the metadata, or split the long table by type. Multiple-select questions often arrive as several columns (<code>Q40_1</code>, <code>Q40_2</code>, <code>Q40_3</code>, one per option), which the long form turns into <code>question = 'Q40_2'</code> rows; split the name into a question and an option with <code>string_split</code> if you need to group them.</p>\n<h2>When the wide shape is the right one</h2>\n<p>Do not unpivot everything by reflex. A wide table suits tools that want a feature matrix, such as a statistics package or a model, where each question is a variable and each respondent a case: reading the long form back into a matrix is another pivot. It also suits questions you will always analyze together, a battery of ten items that make a scale, where the mean across the items per respondent is a one-line expression. And a 302-column table is fine to store in Parquet or a database, where columns are cheap, even if it is miserable to read in a spreadsheet.</p>\n<p>Use the long form for exploring, counting and joining, and the wide form for modeling and for the export you give back. Keep both reproducible from the raw file with a script. For very large survey files, convert once to Parquet to avoid reparsing 300 columns each time (<a href=\"/library/parquet-beats-csv-repeated-analysis\">why Parquet beats CSV for repeated analysis</a>). The statement is documented in DuckDB's <a href=\"https://duckdb.org/docs/2.0/sql/statements/unpivot\" target=\"_blank\" rel=\"noopener noreferrer\">UNPIVOT reference</a>, and its <a href=\"https://duckdb.org/docs/2.0/data/csv/overview\" target=\"_blank\" rel=\"noopener noreferrer\">CSV overview</a> covers reading wide files.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["CSV"]},{"id":"https://datahacks.dev/library/merging-yaml-files-command-line","url":"https://datahacks.dev/library/merging-yaml-files-command-line","title":"Merging YAML Files From the Command Line With yq Safely","summary":"Deep-merge a base and an override with yq eval-all, choose whether lists replace or append, and check what a null or an empty value in the override does.","content_html":"<p>Layered configuration is common: a base file with defaults, a second file with overrides for an environment. In Python a deep merge is a ten-line function. On the command line, in a CI step or a deployment script, <code>yq</code> does it in one expression. This post shows the expression, what each operator does to nested mappings and lists, and the traps around nulls, ordering and writing the result, tested on a base and an override file.</p>\n<h2>The two files</h2>\n<pre data-filename=\"a.yaml\"><code class=\"language-yaml\">server:\n  host: db1\n  port: 5432\n  opts: {ssl: true, pool: 5}\nfeatures: [search, export]</code></pre>\n<pre data-filename=\"b.yaml\"><code class=\"language-yaml\">server:\n  host: db2\n  opts: {pool: 20}\nfeatures: [billing]</code></pre>\n<p>The override changes the host and the pool size and does not mention the port or <code>ssl</code>. It also supplies a different features list.</p>\n<h2>The deep merge</h2>\n<pre><code class=\"language-bash\">yq eval-all 'select(fi==0) * select(fi==1)' a.yaml b.yaml</code></pre>\n<pre><code class=\"language-text\">server:\n  host: db2\n  port: 5432\n  opts: {ssl: true, pool: 20}\nfeatures: [billing]</code></pre>\n<p><code>eval-all</code> (alias <code>ea</code>) reads all the files into one stream of documents before running the expression, unlike plain <code>eval</code>, which runs the expression on each file separately. <code>fi</code> is the file index, starting at 0, so <code>select(fi==0)</code> is the first file and <code>select(fi==1)</code> the second. The <code>*</code> operator is the deep merge: where both sides have a mapping at the same key it recurses, and where they differ the right side wins. The result keeps <code>port: 5432</code> and <code>ssl: true</code> from the base, takes <code>host: db2</code> and <code>pool: 20</code> from the override. A shallow merge would have kept only the override's <code>server</code> block.</p>\n<p>The <code>features</code> list was <strong>replaced</strong> by the override's list, <code>[billing]</code>. That is the default behavior for arrays with <code>*</code>.</p>\n<h2>Appending lists</h2>\n<p>The variant <code>*+</code> appends arrays:</p>\n<pre><code class=\"language-bash\">yq eval-all 'select(fi==0) *+ select(fi==1)' a.yaml b.yaml</code></pre>\n<pre><code class=\"language-text\">server:\n  host: db2\n  port: 5432\n  opts: {ssl: true, pool: 20}\nfeatures: [search, export, billing]</code></pre>\n<p>Same mappings, and the list now holds all three. Other modifiers exist in the <code>yq</code> manual for specific needs: <code>*d</code> to merge arrays by index (deep), <code>*n</code> to only add keys that do not exist in the base (so the base wins), and <code>*?</code> to merge only keys that exist. Pick the one whose meaning matches your policy and write it in a comment next to the command. The point is the same as in the Python function: <strong>the list policy is a decision, and the operator is where it lives.</strong> Replacing is the usual choice for configuration where the override means \"exactly these\", and appending suits lists of things to run or paths to search.</p>\n<h2>Writing the result</h2>\n<p><code>yq</code> prints the merged document to standard output, so redirect it to a new file:</p>\n<pre><code class=\"language-bash\">yq eval-all 'select(fi==0) * select(fi==1)' base.yaml prod.yaml &gt; merged.yaml</code></pre>\n<p>Do not use <code>-i</code> with several input files. In a test, <code>yq -i '...' f1.yaml f2.yaml f3.yaml</code> rewrote only the first file, replacing its content with the concatenation of all three as a multi-document stream, and left the other two unchanged (<a href=\"/library/yq-update-many-yaml-files\">using yq to update many YAML files at once</a>). For a merge, write to a new path, validate it and move it into place.</p>\n<p>To merge more than two files, list them in order. Later files override earlier ones, and the expression reduces across them:</p>\n<pre><code class=\"language-bash\">yq eval-all '. as $item ireduce ({}; . * $item)' base.yaml env.yaml local.yaml</code></pre>\n<p>The <code>ireduce</code> form folds a list of any length with the <code>*</code> operator. The simple <code>select(fi==0) * select(fi==1)</code> form works for exactly two files.</p>\n<h2>What the merge does to nulls and empties</h2>\n<p>A null in the override is a value like any other. If the override has <code>debug: ~</code>, the merged <code>debug</code> is null, replacing the base's <code>false</code>. Whether that is what you want is the same question as in Python: is a null \"unset\" or \"no change\"? <code>yq</code> has an operator to ignore nulls in some forms, and the simplest protection is to not write empty keys in override files. Lint for them with <code>yamllint</code> or a schema.</p>\n<p>A key with an empty mapping (<code>opts: {}</code>) in the override merges as no change to the base's <code>opts</code>, since a deep merge of an empty mapping adds nothing, while a key with <code>opts: ~</code> replaces it. The two look similar in a file and behave differently (<a href=\"/library/yaml-null-values-empty-string\">YAML null values and the empty string question</a>).</p>\n<h2>Comments and layout</h2>\n<p><code>yq</code> keeps the comments of the inputs where it can, and the merged layout follows the first file with the second's new keys added at the end of their mappings. Flow-style mappings from the input (<code>opts: {ssl: true, pool: 20}</code>) stay in flow style. Run a formatter on the result if you commit it, so it matches the repository style. For merged files that are generated at deploy time and never committed, the layout does not matter.</p>\n<h2>Checks after merging</h2>\n<ol><li><p><strong>Load the merged file</strong> with the parser the application uses and print it. A merge can succeed and produce a value that the parser reads differently.</p></li><li><p><strong>Validate against the schema.</strong> Unknown keys from a typo in the override pass through the merge untouched, and a schema with forbidden extras reports them (<a href=\"/library/validating-config-yaml-json-schema\">validating config YAML with JSON Schema</a>).</p></li><li><p><strong>Check for duplicate keys</strong> in each input first. A duplicate in the base is resolved by the parser before the merge, which hides it.</p></li><li><p><strong>Diff the merged file against the base</strong> to review what the override actually changed: <code>diff base.yaml merged.yaml</code>.</p></li><li><p><strong>Keep the layer order in the command,</strong> not in file naming alone, so it is visible in the script.</p></li></ol>\n<h2>A deployment pattern</h2>\n<p>A common use is composing the effective configuration at deploy time. The repository holds <code>base.yaml</code> and one file per environment. The deploy script merges them into a temporary file, validates it, and mounts or uploads it:</p>\n<pre><code class=\"language-bash\">yq eval-all '. as $item ireduce ({}; . * $item)' base.yaml \"env/$ENVIRONMENT.yaml\" &gt; /tmp/effective.yaml\ncheck-jsonschema --schemafile schema.json /tmp/effective.yaml</code></pre>\n<p>I ran the folding form on three files and the result combined all three layers: <code>server</code> took <code>host: db2</code> from the second file and <code>port: 1</code> from the third, with <code>opts</code> merged from the first two, and the extra key from the third appeared at the end. The effective file is an artifact, so do not commit it. Print it in the deploy log with secrets masked, because the first question in any incident is what configuration was actually applied.</p>\n<p>Keep the base free of environment-specific values and the overrides small. An override file that repeats most of the base has stopped being an override, and the next change to the base has to be made in several places. The merge makes small overrides cheap, so use it that way.</p>\n<h2>Related reading</h2>\n<p>The cluster hub is <a href=\"/library/working-yaml-terminal\">working with YAML from the terminal</a>. The same merge in code, with the policy discussion, is in <a href=\"/library/merging-two-yaml-files-python\">merging two YAML files in Python with a deep merge</a>, and layering by environment in <a href=\"/library/environment-specific-yaml-config\">environment specific YAML config without copy and paste</a>. The operators are in the <a href=\"https://mikefarah.gitbook.io/yq/operators/multiply-merge\" target=\"_blank\" rel=\"noopener noreferrer\">yq multiply and merge documentation</a>, and the file-index variable in the <a href=\"https://mikefarah.gitbook.io/yq/operators/file-operators\" target=\"_blank\" rel=\"noopener noreferrer\">yq <code>fi</code> reference</a>. The merge semantics follow the <a href=\"https://yaml.org/spec/1.2.2/#10-recommended-schemas\" target=\"_blank\" rel=\"noopener noreferrer\">YAML 1.2.2 mapping rules</a>. Compare two inputs in the <a href=\"/tools/yaml-formatter\">YAML formatter</a>.</p>\n<h2>When a command-line merge is the wrong tool</h2>\n<p><strong>Lists of records need to merge by key.</strong> Combining two lists of services by name is not what <code>*</code> or <code>*+</code> does. Use a script, or yq's <code>unique_by</code> and <code>group_by</code> operators, with care.</p>\n<p><strong>The platform has its own overlay mechanism.</strong> Kustomize and Helm merge Kubernetes manifests with knowledge of the fields. Use them for those files.</p>\n<p><strong>The merge needs conditionals.</strong> Logic belongs in code with tests.</p>\n<p><strong>You must preserve exact formatting.</strong> Merge, then run the formatter your repository uses, or accept normalized output.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["YAML"]},{"id":"https://datahacks.dev/library/validating-yaml-configuration-pydantic","url":"https://datahacks.dev/library/validating-yaml-configuration-pydantic","title":"Validating YAML Configuration With pydantic Models","summary":"Load YAML, validate it with pydantic models and get every error with its path. The Norway problem caught, extra keys rejected and ranges enforced.","content_html":"<p>Loading a YAML file tells you it is YAML. It does not tell you it is the configuration your program needs. A misspelled key is ignored, a port written as a word survives until it is used, a boolean where a string belongs passes through. The cheapest fix is a schema checked at load time, and in Python the most convenient way to write one is a pydantic model. This post builds one for a small configuration, runs it on a good and a bad file, and shows the errors it produces.</p>\n<h2>The models</h2>\n<p>A configuration with an environment name, a list of servers and a timeout:</p>\n<pre data-filename=\"config.py\"><code class=\"language-python\">from typing import Literal\nfrom pydantic import BaseModel, ConfigDict, Field\n\nclass Server(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    host: str\n    port: int = Field(ge=1, le=65535)\n    tls: bool = False\n\nclass Config(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    env: Literal[\"dev\", \"staging\", \"prod\"]\n    servers: list[Server]\n    timeout: float = 30.0</code></pre>\n<p>The class bodies read like a specification. <code>env</code> must be one of three strings. <code>servers</code> is a list of <code>Server</code>, each with a string <code>host</code>, an integer <code>port</code> between 1 and 65535 and an optional boolean <code>tls</code>. <code>timeout</code> is a number with a default. <code>extra=\"forbid\"</code> makes any key not declared an error, in both models.</p>\n<h2>A good file</h2>\n<pre><code class=\"language-yaml\">env: prod\nservers:\n  - {host: a.example, port: 443, tls: true}\ntimeout: 12</code></pre>\n<pre><code class=\"language-python\">Config.model_validate(yaml.safe_load(good))</code></pre>\n<pre><code class=\"language-text\">env='prod' servers=[Server(host='a.example', port=443, tls=True)] timeout=12.0</code></pre>\n<p>The result is a typed object, <code>config.servers[0].port</code> is an <code>int</code>, and <code>timeout</code> was converted from the integer 12 to the float 12.0. Code downstream can rely on the types. (pydantic converts compatible values by default, which is called lax mode; <code>ConfigDict(strict=True)</code> switches it off when you want <code>12</code> rejected for a float field.)</p>\n<h2>A bad file</h2>\n<pre><code class=\"language-yaml\">env: production\nservers:\n  - {host: a.example, port: \"https\"}\n  - {host: b.example, port: 70000, tls: maybe, extra_key: 1}\ntimeout: soon\nretries: 3</code></pre>\n<p>Validating it:</p>\n<pre><code class=\"language-python\">try:\n    Config.model_validate(yaml.safe_load(bad))\nexcept ValidationError as e:\n    print(e.error_count(), \"errors\")\n    for err in e.errors():\n        print(\" \", \".join(str(p) for p in err[\"loc\"]), \"-&gt;\", err[\"msg\"])</code></pre>\n<pre><code class=\"language-text\">7 errors\n  env -&gt; Input should be 'dev', 'staging' or 'prod'\n  servers.0.port -&gt; Input should be a valid integer, unable to parse string as an integer\n  servers.1.port -&gt; Input should be less than or equal to 65535\n  servers.1.tls -&gt; Input should be a valid boolean, unable to interpret input\n  servers.1.extra_key -&gt; Extra inputs are not permitted\n  timeout -&gt; Input should be a valid number, unable to parse string as a number\n  retries -&gt; Extra inputs are not permitted</code></pre>\n<p>Seven errors from one run. That is the main advantage over writing checks by hand: pydantic collects every error instead of stopping at the first, and each one has a <strong>location path</strong> (<code>servers.1.port</code> is the port of the second server) and a plain message. That output can go straight into an error message for the person who edited the file. The misspelled-key class of mistakes shows up as \"Extra inputs are not permitted\", which is what <code>extra=\"forbid\"</code> is for. Without it, <code>extra_key</code> and <code>retries</code> would have been silently ignored, and the typo <code>tsl: true</code> would have left TLS off.</p>\n<h2>It catches the YAML type traps</h2>\n<p>The type declarations catch the surprises that YAML's type guessing introduces. A country code field:</p>\n<pre><code class=\"language-python\">class C(BaseModel):\n    country: str\nC.model_validate(yaml.safe_load(\"country: NO\"))</code></pre>\n<pre><code class=\"language-text\">country: NO -&gt; Input should be a valid string | input: False</code></pre>\n<p>PyYAML loaded the unquoted <code>NO</code> as the boolean <code>False</code>; the field is declared <code>str</code>; pydantic rejected it and reported the input it received, <code>False</code>. The error is a loud, early signal that the file needs quotes (<a href=\"/library/norway-problem-yaml-boolean-surprises\">the Norway problem and YAML boolean surprises</a>). Compare the failure mode without validation: a <code>False</code> flowing into code that expected a code, found when a customer in Norway cannot be selected.</p>\n<p>The same applies to versions loaded as floats, identifiers with leading zeros loaded as numbers and times loaded as integers. Declare them <code>str</code>, and the loader tells you. By default pydantic's lax mode does <em>not</em> convert numbers to strings, so a <code>str</code> field given <code>3.1</code> for a version written <code>3.10</code> is rejected, which is the behavior you want here (<a href=\"/library/yaml-version-numbers-turning-floats\">YAML version numbers turning into floats</a>).</p>\n<h2>Useful features</h2>\n<p><strong>Defaults and optional fields.</strong> <code>timeout: float = 30.0</code> makes the key optional. Use <code>str | None = None</code> for fields that may be absent or null.</p>\n<p><strong>Constraints.</strong> <code>Field(ge=1, le=65535)</code>, <code>min_length</code>, <code>pattern</code> for strings, <code>Literal[...]</code> for enumerations. Put the constraints in the model, and the error messages describe them.</p>\n<p><strong>Custom validators.</strong> For rules involving several fields (a <code>tls</code> server must have port 443), use <code>@model_validator</code>. For parsing special strings (<code>\"30s\"</code> into seconds), use <code>@field_validator</code>.</p>\n<p><strong>Nested models and lists</strong> validate recursively, with paths that show where.</p>\n<p><strong>Secrets.</strong> <code>SecretStr</code> hides a value in logs and error output, which matters for passwords in configuration.</p>\n<p><strong>Environment overrides.</strong> The companion package <code>pydantic-settings</code> reads values from environment variables with the same models, which makes layering file and environment values straightforward (<a href=\"/library/environment-variable-substitution-yaml-config\">environment variable substitution in YAML config</a>).</p>\n<h2>Where to put the validation</h2>\n<p>Validate once, at the boundary where the file is loaded, and pass the typed object to the rest of the program. Put the load function and the model in one module that the whole application imports:</p>\n<pre><code class=\"language-python\">def load_config(path) -&gt; Config:\n    with open(path, encoding=\"utf-8\") as f:\n        data = yaml.safe_load(f) or {}\n    return Config.model_validate(data)</code></pre>\n<p>A failing load exits with the full list of errors at start-up, not hours later in a code path nobody exercised. In CI, add a test that loads the example and production configuration files, so a bad edit fails the pull request. The same model can generate a JSON Schema (<code>Config.model_json_schema()</code>) for editors and for tools that validate outside Python (<a href=\"/library/validating-config-yaml-json-schema\">validating config YAML with JSON Schema</a>).</p>\n<h2>Showing errors to the person who wrote the file</h2>\n<p>The error list is the raw material for a good message, and a little formatting makes it better. Print the file name, then one line per error with the path in a form the author recognizes (<code>servers[1].port</code>) and the message. Add the received value when it is short, which pydantic includes in each error as <code>input</code>; for a bad port, \"got 70000\" is more useful than \"less than or equal to 65535\" alone. Group errors by top-level section when the list is long. And exit with a nonzero status so scripts and CI notice.</p>\n<p>If your users want line numbers, there are two routes. Compose the YAML into a node tree with <code>yaml.compose</code>, which keeps the start mark of every node, and look up the path from each error to find the line. Or load with <code>ruamel.yaml</code> round-trip mode, whose containers expose <code>lc.line</code> and <code>lc.col</code> for keys and items. Either costs thirty lines and turns \"servers.1.port is invalid\" into \"line 7, column 30: port is invalid\", which is worth it for files that non-programmers edit.</p>\n<p>A last tip is to validate example files in the documentation. Keep an <code>example.yaml</code> that you ship in the repository and load it in a test with the model. When someone adds a field to the model and forgets the example, the test fails, and the example stays truthful.</p>\n<h2>Related reading</h2>\n<p>The cluster hub is <a href=\"/library/reading-writing-yaml-python-safely\">reading and writing YAML in Python safely</a>. The loading step is in <a href=\"/library/loading-yaml-python-pyyaml-safe\">loading YAML in Python with PyYAML safe_load</a>. The model features are documented in the <a href=\"https://docs.pydantic.dev/latest/concepts/models/\" target=\"_blank\" rel=\"noopener noreferrer\">pydantic documentation</a> and its <a href=\"https://docs.pydantic.dev/latest/errors/validation_errors/\" target=\"_blank\" rel=\"noopener noreferrer\">validation errors reference</a>. Check how a value loads in the <a href=\"/tools/yaml-json\">YAML to JSON tool</a>.</p>\n<h2>When pydantic validation is not enough</h2>\n<p><strong>Other languages read the file.</strong> A Python model validates for Python. Export the JSON Schema and validate in CI for every consumer.</p>\n<p><strong>The file is huge.</strong> Validation of thousands of records is fast in pydantic 2 and still not the right tool for bulk data.</p>\n<p><strong>The constraints are external.</strong> A port that must be free, a host that must resolve and a file that must exist are checks against the world. Do them in a separate start-up step with clear messages.</p>\n<p><strong>You need the original text.</strong> Validated objects have lost comments and line numbers, so errors cannot point to a line. If line numbers matter to your users, validate the loaded nodes with a library that tracks marks, or map paths to lines by composing the YAML tree.</p>","date_published":"2026-10-30T16:51:00+00:00","tags":["YAML"]}]}