Repository navigation
29.0.0
Pre-releaseHighlights
~104 commits since 28.0.0, and the theme is correctness. We develop qsv with the latest models from Anthropic and review every change adversarially with OpenAI's. Codacy adds one more layer of correctness review on top of that, and it gives us free access to its platform. With all three, we swept several core commands for correctness: stats and moarstats, luau, profile, scoresql, sqlp --format avro, and every command that reads piped stdin. Thank you, Codacy.
If you run stats on data written by pandas or numpy, upgrade:
statsno longer crashes onNaNcells (#5053). In qsv 28.0.0 and 27.0.1,stats --median,--quartilesor--everythingpanics on a float column of a few hundred rows or more that holds aNaN,nanorNANcell, which is how pandas and numpy write missing values. So doesmoarstats, which runsstats. On smaller columns, thesumcame out empty and the quartiles, MAD, IQR, fences and percentiles were empty or shifted. Every numeric statistic now skipsNaNcells, as the mean and--quantile-method approxalready did. ANaNcell still counts as a value fornullcount,sparsityandcardinality.
Also in this release:
validate --sarifwrites JSON Schema errors as a SARIF 2.1.0 log for GitHub code scanning and other SARIF tools, with an example GitHub Actions workflow (#5031).- Piped stdin works in eleven more places.
sample,cat,applydp,geocode --batch 0,input --skip-lastlines,partition,split --chunks,searchset,frequency,sniffandextsortdropped rows, wrote only the header, or rejected-when reading from a pipe, several of them with exit 0 (#5003, #5005, #5009, #5012). - Maps render in sandboxed iframes such as CKAN's Webpage view. Before, every MapLibre map there stayed blank (#5033).
- The stats cache is no longer served stale when
QSV_NO_HEADERS,QSV_STATS_SEPARATORor a sniffed or default delimiter changes, or after an upgrade with--infer-dates(#5049, #5051, #5052). stats --cardinality,--modeand--everythingare 12–27% faster and use up to 36% less memory, with and without--weight(#5011, #5013).sqlpruns on a newer Polars that adds correlated subqueries,LATERAL,PERCENTILE_CONT/PERCENTILE_DISC,CREATE VIEW,BOOL_AND,COUNT_IF,MAX_BY,REGEXP_EXTRACTand more. It also fixes two wrong results (#5055 and earlier bumps).- Dependency bumps for security fixes, and bundled-library updates.
- Security:
- The SQLite that
to sqlitebundles is now 3.54.1, which fixes RUSTSEC-2026-0459 (#5022). - The Polars bump moves to zstd 0.14, which clears 13 "unsound" advisories (#5021).
- The MCP server takes
honoand@hono/node-serversecurity releases (#5042, #5043).
- The SQLite that
- Bundled libraries:
- Security:
This is a major release because some sqlp, validate, profile --strict, luau and stats results and exit codes change. See Breaking Changes. The MSRV is now Rust 1.100.
🙏 Contributors
Thanks to everyone who made 29.0.0 possible:
- @acdha for reporting that
samplebehaved differently on stdin (#4999). That report led to the stdin fixes across eleven commands. - @mitsuhiko for adding a bundled time zone database fallback to MiniJinja's datetime filters at our request (mitsuhiko/minijinja#1008). With it,
templatehandles legacy zone names such asEST. - @ik1ne for merging the zip 9 bump in
webdriver-downloaderand releasing it as 0.18.0 (ik1ne/webdriver-downloader#67), which let qsv drop its fork patch
Breaking Changes
sqlp:EXPLAINtakes queries only (#5055, pola-rs/polars#30798).EXPLAIN DROP TABLE,EXPLAIN CREATE VIEW,EXPLAIN CREATE TABLE,EXPLAIN TRUNCATE TABLEandEXPLAIN SHOW TABLESnow fail withEXPLAIN supports queries only. Before,EXPLAINran such a statement to get its plan, soEXPLAIN DROP TABLE tdropped the table.sqlp: some results change with the Polars bump. See the Polars entry under Changed for the full list:IS DISTINCT FROMbinds tighter thanAND/OR(pola-rs/polars#30367).POSITION/STRPOSof a NULL string returns NULL, not0. String length, position and date-part functions return Int64 (pola-rs/polars#30493).- A window function query with no outer
ORDER BYmay return its rows in a different order (pola-rs/polars#30494).
validate: rows that passed can now fail with jsonschema 0.61.2. Thedate-time,dateandtimeformats require digits where RFC 3339 does..inpatternno longer matches\r, U+2028 or U+2029. Whole floats past 2^53 are compared exactly against numeric bounds. See Fixed for the details.profile --strictfails where it used to pass (#5025). With--validate, it fails on a file that failsqsv validate, or when that pass cannot run. With or without--validate, it fails on an--initial-contextoverride that cannot be applied. Before, all of these exited 0.luau: an END script error exits with 1 (#5036). Before, it printed the error and exited 0. BEGIN and MAIN script errors already exited with 1.statsoutput changes. See Fixed for the details:- Every numeric statistic of a float column with
NaNcells is now computed without them, and a column of onlyNaNcells has no sum (#5053, #5056). - With
QSV_NO_HEADERSset, fields are numbered0,1, …, as with--no-headers. Before, they were named after the first row while that row was also counted as data (#5049). --modewithout--cardinalityreports*ALLfor an all-unique column, instead of listing its values (#5050).max_precisionchanges for values below 0.00001 or from 1e16 up, and no longer countsNaNorinfcells (#5050).- With
--weight, a column whose rows all have a zero or negative weight has no median, quartiles, MAD or percentiles, and the*ALLantimode counts only rows with a positive weight (#5050).
- Every numeric statistic of a float column with
Added
validate --sarif <file>: write JSON Schema validation errors as a SARIF 2.1.0 log (#5031), for code scanning tools such as GitHub code scanning. Each invalid field becomes a result. The result has a rule per JSON Schema keyword (plusdynamicEnum,uniqueCombinedWithandragged-rowwith--split-ragged) and points at the line its row starts on. That line accounts for multi-line quoted fields, CRLF line endings, blank andQSV_COMMENT_CHARcomment lines, andQSV_SNIFF_PREAMBLEpreambles. A compressed or converted input (e.g..csv.gz) has no such line, so its results name only the file. A run that stops early (on a ragged row without--split-ragged, or a read error) writes no SARIF file, and removes one left by an earlier run so it can't be uploaded as current. Invalid UTF-8 doesn't stop a JSON Schema run; it is reported like any other error. The file is written even when every row is valid (with no results), so a CI run clears earlier findings. Output stops at 25,000 results by default, GitHub code scanning's limit per run, with a notification when results are cut.--sarif-max-results <n>sets another cap for tools that accept more, and 0 means no limit. The file URI is relative to the root of the git checkout the CSV is in, so results point at the right file wherever qsv runs; outside a checkout, it is relative to the current directory. A--sarifpath that is the input CSV or the schema (including through a link) is refused. The option works only in JSON Schema mode with a file input; RFC 4180 mode and stdin reject it. Thevalidation-errors.tsvreport is unchanged. An example is inresources/examples/validate-sarif: a sample CSV with one error of each kind, its schema, the SARIF log qsv writes for them (a test keeps it current), and a GitHub Actions workflow that uploads it to code scanning.- docs: "Statistics at a Glance" in
docs/STATS_DEFINITIONS.md: qsv compiles 159 statistic columns acrossstats(48),moarstats(76 + 14 bivariate),pragmastat(18) andfrequency(3). A new test checks the table against real output, so the counts can't go stale silently (#4996). - docs: the
vizUSAGE documents stdin input (#5018). Pass-as<input>to chart piped CSV. Every chart type accepts it exceptsmart, which needs a file for its cached statistics. With--geojson autoorcensus, stdin is first copied to a temp file. There's also a new piped-input example. profile --initial-context: a final-in adataset_infopointer appends to an array (#5025, #4991). For example,/projection/hasQualityMeasurement/-adds an agency's own quality measurement after the computed ones, so it no longer takes a second run to seed the whole array.-appends only to an array that exists; a numeric index still replaces the item in place.- docs: the
profile --locate censusUSAGE gives the state-column threshold (#5025): a state column lists states only when at least 90% of its values are US states.
Changed
- MSRV is now Rust 1.100 (was 1.99; #5039).
geocode: the default index is rebuilt from current Geonames data, so some place labels differ from 28.0.0. For example, Toronto now has an admin2 (county-level) name of "Toronto", so%city-admin2-admin1gives "Toronto, Toronto, Ontario" where it gave "Toronto, , Ontario". Antwerp's admin2 is now "Antwerp", not "Antwerp Province". To keep 28.0.0's labels, load that release's index withqsv geocode index-load.- build: qsvlite and qsvdp no longer compile crates they don't use. Rust 1.100's new
unused_dependenciesCargo lint found eight dependencies that only the fullqsvbinary needs. They are now optional and turned on by the features that use them:const_formatandminijinja-contrib(feature_capable, andprofileforminijinja-contrib),semver(self_update), anddunce,ext-sort,grex,hostname-validatorandjaq-json(feature_capableandlite). Every binary is built from the same code as before, so the lite and dp builds just compile a little faster (#5039). viz smart: the guided Tour's vendored driver.js is now 1.14.0 (was 1.10.2; 1.12.1 in #5038, then 1.13.0 and 1.14.0 in #5057):- Enter moves to the next step on every step. Each popover focuses Next instead of the close button, and the Tour no longer moves focus off Next on the first step. Before, Enter did nothing on the first step and ended the tour on every later one.
- Escape pressed in a text field inside the spotlighted element, such as the data viewer's search box, goes to that field, and the drawer stays open. Before, it ended the tour. While a tour runs, the dictionary and data drawers leave Escape to the tour, so Escape anywhere else ends the tour and closes the open drawer, as before.
- Elements inside the spotlighted area keep their own
pointer-events: none; before, driver.js forced every one of them toauto. - From 1.13.0 and 1.14.0: the spotlight and popover cover only the part of an element that is in view, so an element wider than its scrolling container is highlighted inside it. The highlight follows a panel that scrolls inside the page, and a step follows its element when the page re-renders it. The popover scrolls into view only as far as it needs to.
- The bundle is larger: each Tour page embeds about 12.5 KB more (gzip + base64). The embedded payloads, the
QSV_VIZ_CDNjsDelivr URLs and their SRI digests are updated together, and every gallery Data Schematic is regenerated with them.
- deps: the
webdriver-downloaderfork patch is gone (#5027). ik1ne/webdriver-downloader#67 (zip 9) was merged and released as 0.18.0. qsv'splotlypin moves todathere/plotlyrevd43be37, whoseplotly_staticbuild dependency requires 0.18, sowebdriver-downloadernow comes from crates.io. The fork was added in #4828 because every zip 8.x was yanked. - deps: other bumps:
calamine0.38 (used byexcelanddescribegpt's spreadsheet context files),rfd0.18 (used byprompt),toml1.2.csvlens(used bylens) now comes from a fork of YS-L/csvlens with updated dependencies. luau: Luau bumped to 0.745 (from 0.740, #5026).luau0-src0.23.0+luau745 needs an unreleased mlua-sys, somluais patched to upstream reva0ad453until an mlua release includes it. The crate versions are unchanged (mlua 0.12.2, mlua-sys 0.13.0). The yank that reverted the 0.744 bump (#4923) is resolved: 0.23.0 replaces the yanked 0.22.1+luau744.- Native code (
luau-jit) fixes (0.744): an Arm64 crash in functions with more than 256 constants, a dead-store-elimination bug, aFORGLOOPregister reservation bug, andbit32/integer correctness fixes (lrotate,extract,replace). - Faster:
string.gsubwith patterns that have no special characters (0.742),table.find(0.743), andtable.create/table.clearas fast calls (0.745).
- Native code (
profile --validate --strictfails on a file that failsqsv validate(#5025). Before, an input with inconsistent field counts or quoting, or invalid UTF-8, passed--strict: it was recorded only as asyntacticValiditymeasurement of"false". It now also gets a RequiredsyntacticValiditywarning inprojection_warnings, and--strictfails on it. The same happens when theqsv validatepass cannot run, sinceprofilecan then not vouch for the file.profile --strictfails when an--initial-contextoverride cannot be applied, with or without--validate(#5025, #4991). See Fixed.stats:--cardinality,--modeand--everythingare faster and use less memory (#5011). qsv-stats 0.57 stores short values inline when counting them (ByteKey). On a 1M-row file,--cardinality --modeis 16–27% faster, and--everythingon an indexed file is 27% faster with 34% less peak memory. Output is unchanged; defaultstatsis unaffected. On big-endian targets (s390x), values of 128 bytes or more were misread until a compact_bytes fix (#5015), so this was never released with the bug.stats --weight: mode, antimode and cardinality tracking is faster and uses less memory (#5013). The weighted tracker now stores short values inline too, as #5011 did for unweightedstats. On a 1M-row file with--weight,--cardinality --modeis 12–20% faster, and--everythingon an indexed file is 21–23% faster with 36% less peak memory. Output is unchanged.scripts/benchmarks.sh10.1.1: thejoinpbenchmarks check their exact row counts (#4997). Before, three of them only checked for at least one output row andjoinp_left_antihad no check, so a partial join could be published as a speedup. Now inner and--streamingmust return 883,470 rows,--leftall 1,000,000, and--left-anti116,530 (cross-checked withqsv joinand DuckDB).scripts/benchmarks.sh10.1.2: thesqlpandfrequencybenchmarks check their results (#5019). An empty or partial result that still exits 0 is now quarantined, not published as a speedup.- The 25
frequencybenchmarks require every field's counts to add up to the row count, and every selected column to be present. - 13
sqlpbenchmarks must return the exact expected rows or groups (cross-checked with DuckDB), in CSV, JSON, JSONL, Parquet and Arrow output. - Also fixed: an output check on a benchmark that has a
--concludeor--cleanuphook checked the hook's output instead, because hyperfine writes it to the same file.
- The 25
- deps: Polars bumped to upstream rev
2d9938d(frompy-2.1.0, 283 commits:0b47007in #4993,a598c51in #5021,a9bce97in #5030,ff1b37din #5034,8ecd1cain #5041,61fdb30in #5045,36ea0f9in #5048, then2d9938din #5055). The2d9938dstep is 8 commits. Its onesqlpchange:EXPLAINnow takes queries only (#30798). Before,EXPLAINover another statement ran it to get a plan, soEXPLAIN DROP TABLE tin a script dropped the table andEXPLAIN CREATE VIEW ...created the view; these now fail withEXPLAIN supports queries only, as doEXPLAIN CREATE TABLE,EXPLAIN TRUNCATE TABLEandEXPLAIN SHOW TABLES. It also has two speedups: group-bys skip hashing and table lookups for runs of equal string keys (#30794), and dictionary casts and sorted searches copy less (#30790). Nosqlporpivotpresult changed in side-by-side tests ofGROUP BYs, joins and window functions over sorted and clustered string keys (with and without--streaming), and of everypivotpaggregation. A pivot fix forpl.element()insideevalaggregations (#30781) doesn't apply:pivotpbuilds noevalaggregations. The other commits are Python- or Iceberg-only, or need a polars feature qsv doesn't enable (rolling_*_by, #30795). The36ea0f9step is 17 commits and changes nosqlpresult that already worked. It adds SQL that used to fail: comma-separatedLATERALderived tables, and aggregates withoutGROUP BYinside them (FROM t, LATERAL (SELECT MAX(v) FROM t2 WHERE t2.k = t.k) a, LATERAL (...) b; #30768);CREATE VIEW,DROP VIEWandDESCRIBE <table>(#30778); and a leading~inread_csv/read_parquet/read_ipc/read_ndjsonpaths now means the home directory (#30774). Date parts are now computed with integer arithmetic (#30777); everyEXTRACTfield gave the same result on both revs for 21,260 dates and timestamps from year 1 to 9999. Arithmetic with a literal on the left now plans with the type it runs with (#30765), and temporaltotal_*overflows give NULL (#30743); neither changed asqlpresult in side-by-side tests. The61fdb30step is 11 commits. Its onesqlpresult change is a fix: a signed integer literal combined with an unsigned value no longer gives NULL for the negative rows (#30742). For example,CASE WHEN side = 'buy' THEN 1 ELSE -1 END * CAST(qty AS UINTEGER)returned NULL for the-1rows, so aSUMover it skipped them. It also addsBOOL_AND,BOOL_OR,COUNT_IF,MAX_BY,MIN_BY,ANY_VALUE,APPROX_COUNT_DISTINCTandREGEXP_EXTRACTtosqlp(#30763); qsv now enables polars'approx_uniquefeature forAPPROX_COUNT_DISTINCT.REGEXP_EXTRACT(s, pattern[, group])returns the whole match or the given capture group, and an empty string when nothing matches. The other fixes leftsqlpresults unchanged in side-by-side tests: calendar-part offsets applied in one step (#30744, checked with month-endINTERVALarithmetic), and population variance of a single null in a sorted group-by (#30747;sqlp'sVARIANCE/STDDEVare the sample forms, which this doesn't touch). The8ecd1castep is 14 commits of performance work: quantiles and medians computed by selection instead of sorting, in parallel (#30569, #30570), multi-column join and group-by keys packed into fixed-width integers (#30708), and joins that skip the probe side when the build side has no non-null keys (#30692). Nosqlpresult changed in side-by-side tests of quantiles, medians,NTILE/PERCENT_RANK, multi-keyGROUP BYs and joins, and joins on all-NULL keys. Theff1b37dstep is 10 commits. Its onesqlpresult change is a fix: aCASEwhoseELSEapplies a strictCASTno longer fails on values from rows that take an earlier branch (#30682). For example,CASE WHEN a THEN 0 ELSE CAST(COALESCE(v, -1) AS UTINYINT) ENDused to fail whenvwas NULL only in rows whereais true. The rest is DecimalSUM/AVGand Parquet string decoding speedups (#30704, #30632), and slice pushdown and unnest fixes that leftsqlpresults unchanged in side-by-side tests. Thea9bce97step is 11 commits of join planning and performance: out-of-core joins (#30679), join reordering (#30680, #30694), a streaming build-side choice from estimated row counts (#30681), and a fix for order relaxation in sliced maintain-order joins (#30684). Nosqlpresult changed in side-by-side tests. Dividing a number by a date or timestamp still fails, now with a clearer message when the schema is resolved (#30163). Thea598c51step is mostly performance, including a faster CSV reader (#30639) and spillable streaming joins; nosqlpresult changed in side-by-side tests. Crates stay 0.55.1; it moves to zstd 0.14 (#30316), so the 13 zstd "unsound" advisory ignores (#4930–#4942) are dropped from.cargo/audit.toml. Somesqlpresults that already worked change:IS DISTINCT FROMbinds tighter thanAND/OR(#30367).a IS DISTINCT FROM b AND c OR dis now((a IS DISTINCT FROM b) AND c) OR d; before, theAND/ORbecame part of the right operand.POSITION/STRPOSof a NULL string is NULL, not0(#30493). String length, position and date-part functions also return Int64.- Known issue:
COUNT(*)over aUNION ALL ... LIMITpanics again (not implemented for dtype UInt128). The #30150 fix regressed in #30363; reported as pola-rs/polars#30523. Workaround: count a column (COUNT(g)) instead ofCOUNT(*), or add anORDER BYinside the limited union. (COUNT(*)over aUNION ALLwithoutLIMITpanicked on py-2.1.0 too.) - A window function query with no outer
ORDER BYmay return rows in a different order (#30494). Add anORDER BYif you depend on row order. - New SQL: join keys of different numeric types (#30401); correlated subqueries with any outer condition,
GROUP BY,HAVING,LIMIT,ANY/ALL, andLATERALderived tables (#30378, #30453, #30466, #30459, #30490, #30467);PERCENTILE_CONT/PERCENTILE_DISC ... WITHIN GROUP(#30442); non-literalLIKEpatterns (#30364, #30372); typed numeric/string literals (#30486);LIMIT/OFFSETexpressions such as scalar subqueries (#30425); repeated unnamed constants (#30488);RANDOM(),REPEAT,CARDINALITY, constantGROUP BYkeys and row values inIN (subquery).
Fixed
stats: aNaNcell in a float column is skipped by every numeric statistic (#5053). ANaN,nanorNANcell is read as a float (pandas and numpy write missing values this way). The mean already skipped it, but thesumcame out empty, and the exact median, quartiles, IQR, MAD, fences, skewness and percentiles were computed with it. On a column of a few hundred rows or more that crashedstats(qsv 28.0.0 exits with a panic onstats --quartiles, as doesmoarstats, which runs it); on a smaller one they came out empty or shifted. With--weight, one such cell emptied the weighted mean and standard deviation of the whole column. They now all skip it, as--quantile-method approxalready did, so the statistics are those of the column's other values. The cell still counts as a value:nullcount,sparsityandcardinalityare unchanged, and the column stays Float. That holds when the other values are all integers too: such a column is summarized as floats, so its minimum and maximum read1.0and not1, and its sum is no longer exact past 2^53. A column whose cells are allNaNhas no sum, as it has no mean (#5056).infcells are unchanged.stats: the cache follows the delimiter set byQSV_SNIFF_DELIMITERandQSV_DEFAULT_DELIMITER(#5052). The stats cache recorded only the--delimiterflag. A semicolon-separateddata.csvread as one comma-delimited column kept that one-column cache afterQSV_SNIFF_DELIMITERwas set, sostatsand the commands that read its cache went on describing the wrong columns. The cache now records the delimiter the file was read with, andstatsand its readers compare against the delimiter they resolve the same way. Checking the cache still does not decompress a compressed or Parquet input. A cache written before this change did not record the delimiter it was read with, so it is recomputed on its next use.moarstats: twoQSV_STATS_SEPARATORfixes (#5051):mode_zscoreis computed for a column with several modes under any separator. The first mode was looked up by splitting on|, so with another separator a multi-modal numeric column had an emptymode_zscore.- A stats CSV written with another separator is recomputed. Before, its percentile cells could not be split, so
kelly_skewnessandpercentile_ratio_90_10were left out without a warning.moarstatsnow reads the separator recorded in the stats cache metadata (#5049) and rerunsstatswhen it differs, with a warning. A stats CSV that has no metadata file is used as it is.
stats: three stale-cache fixes (#5049):QSV_NO_HEADERSnow counts for the stats cache and the field names. Before, the cache only recorded the--no-headersflag, so stats computed with the header row as names were served to a run that set the environment variable (and the reverse). A run with the variable also named the fields after the first row while counting that row as data; it now numbers them0,1, ... as--no-headersdoes. The commands that read the stats cache check it against the same resolved setting, sosortcheckandextsortstill use a cache built under the variable.statsrecomputes whenQSV_STATS_SEPARATORchanges. Before, it served its cache written with the previous separator, so modes, antimodes and percentiles kept the old one. This coversstatsitself;moarstatsstill reads an existing<FILESTEM>.stats.csvas it is.--infer-datessniffs the date columns again after a qsv upgrade. With the default--dates-whitelist sniff, the column list sniffed by the previous version was reused for the recompute that the upgrade triggers, and saved into the new cache. It stayed until the file changed or--forcewas used.
stats: wrongmax_precision, weighted quantiles and*ALLantimodes (#5050):max_precisionis right for very small and very large floats. A value below 0.00001 or from 1e16 up was miscounted:0.000001reported 4 decimal places, and1e21reported 5. They now report 6 and 1.NaNandinfcells no longer count toward it.--weight: a column whose rows all have a zero or negative weight has no median, quartiles, MAD or percentiles. Before, these fell back to the unweighted values, next to a weighted mean of 0. Weighted runs also use less memory: they no longer keep a second, unweighted copy of each numeric column (8 bytes per row).--weight: the*ALLantimode counts only rows with a positive weight. Before, one zero-weight row made an all-unique, equally weighted column list every value as a mode.--modewithout--cardinalityreports*ALLfor an all-unique column. Before, it listed the values (*PREVIEW: ...past 10) and gave their number asantimode_count;*ALLappeared only with--cardinalityor--everything. A column with no rows no longer reports*ALL.
viz smart: category-axis labels no longer land under the wrong bar when the categories look like numbers. (#5047) Frequency bars, measure-by-dimension bars, the Top Relationships lollipop and funnels set their axis labels withtickvalsholding each category's position (0, 1, 2…). Plotly.js matches a category-axis tickval against category names first, so on an axis of codes likeday_of_week(6, 7, 5, …, 0) the label for position 0 went under category "0", and so on. Hovering a bar showed one value while the axis said another, and labels that collided were dropped.tickvalsnow hold the category values themselves. This neededAxis::tick_valuesto accept strings, so qsv'splotlypin moves todathere/plotlyrevb652677. The gallery Data Schematics are regenerated.- docs:
sqlp,joinpandschemaUSAGE corrections (#5046).sqlp's<=>is NULL-safe equality (the same asIS NOT DISTINCT FROM: true when both sides are equal or both are NULL, never NULL), not a -1/0/1 "spaceship" comparison. A Polars schema file is named after the full input file name, sodata.csv's isdata.csv.pschema.json, notdata.pschema.json. luau: fixes from a review ofluau.rs(#5036):- An existing
./date.luais no longer overwritten and deleted. A script usingrequire "./date"made qsv write the bundled LuaDate over anydate.luain the current directory, then delete it at exit. An existing file is now used as-is and left alone. The bundled copy is also removed when the run fails; before, a failed run left it behind. luau filterno longer keeps every row after the first error. The keep check looked at the running error count, so after one row errored, every later row was kept whatever it returned. An errored row is still kept.qsv_insertrecord()rows have the output's width. It padded and truncated to the input's column count (and let one extra value through), so the inserted row was right only formapwith exactly one new column. With two new columns, underfilter, or with too few values, the run failed withfound record with N fields. With--no-headersand unnamed columns, an insert from the MAIN script takes the mapped row's width; one from BEGIN still assumes a single new column, since no row has been mapped yet.- After a MAIN-script
qsv_insertrecord(), later rows no longer each get a blank inserted row. qsv_insertrecord()keeps numbers and booleans. They were written as empty fields;qsv_insertrecord("p", 42, true)now writesp,42,true.luau map --remapworks with a single return value for one new column. Only table returns dropped the input columns, somap c --remap 'return a * 2'failed with a field-count error. (A single value for two or more new columns still fails, with or without--remap; return a table.)- An END script error now exits with 1, like BEGIN and MAIN errors. Before, it printed the error and exited 0.
- The END script's output is printed with
--progressbar. It was dropped whenever the progress bar was on. - Script keywords are matched as whole words. A column whose name contains
return(e.g.returns) no longer stops qsv adding the implicitreturnto a one-expression script. A column whose name contains_INDEXor_LASTROW(e.g.PRICE_INDEX) no longer switches on random-access mode, which needs an index. - The random-access progress bar's length is now the row count; it was the starting row.
- An existing
viz: map panels now load in a sandboxed frame withoutallow-same-origin(#5033, #4946), such as the CKAN Webpage view the wiki recommends (sandbox="allow-scripts allow-popups allow-downloads"). Before, every MapLibre map (viz map,viz choropleth --map, and the point and region maps in a Data Schematic) stayed blank there with no error. The MapLibre in plotly.js 3.x drops messages between the page and its web worker when their origins differ, and in such a frame the page reports its URL's origin while the worker reports"null". Each page now carries a smallWorkerwrapper that gives those messages the origin MapLibre already accepts forfile://pages. It only acts in a sandboxed frame that isn't afile://page, so maps elsewhere behave as before. Checked in Chrome, Firefox and Safari, for region and clustered point maps. The note added in #4947 above each map in such a frame is gone, since the maps now render. The wrapper can go once plotly.js bundles MapLibre 6.17 or later (maplibre-gl-js#8912).validateis stricter and more correct with jsonschema 0.61.2 (from 0.60.2). Rows that passed before can now fail:format: "date-time","date"and"time"require a digit wherever RFC 3339 does (jsonschema#1829). Before,+,,,-,.or/passed in many digit positions, so2026-11-0/T10:00:00Zand-----11-06T10:00:00Zwere valid date-times.- Numeric keywords judge whole floats past 2^53 correctly (jsonschema#1825). A float equal to an integer bound past 2^53 was converted to the wrong integer, so
exclusiveMinimumadmitted the bound itself andconstrejected it. .inpatternandpatternPropertiesno longer matches\r, U+2028 or U+2029, so a value containing one of these line terminators can now fail a pattern it passed before.- Schema fixes:
oneOf/anyOfno longer reject valid rows when a branch's$refsits under an$id, and$refpointers through a numeric key (#/responses/200) now resolve the references under their target. - Validation is slightly faster. On the 1M-row NYC 311 sample (release build; a 41-property schema with type, length, enum and range checks),
validateexecutes 3.7–4.0% fewer instructions when every row is valid, and 2.6–2.7% fewer with one error per row. That makes it 2–3% faster with--jobs 1, with no measurable wall-clock change at the default thread count. Upstream's larger figures (up to 40% withpreserve_order) are best cases for the validation call itself; these are end-to-end results on this file. - Large bounded counts over one character class compile and work (0.61.1). Patterns such as
^.{0,262144}$or[^\p{C}]{1,2048}are no longer unrolled, so they fit the default--size-limit. One such as^.{0,404600}$no longer fails with a regex engine error on an empty value once--size-limitis raised to compile it. Anchored character-class patterns such as^[a-z0-9-]+$are also matched without a regex engine. This needs no change to the schema. These speedups weren't measured on qsv's benchmark file, whose schema has no patterns.
scoresqlno longer marks down well-formed queries (#5028):- Join keys are found when
ONstarts a line or the keys are unqualified (JOIN b\nON a.id = b.id,ON id = id2). Before, such joins were reported as "possible cartesian join" and lost 25 points.
A join inside a subquery ends at the subquery's closing parenthesis, so outer-query columns aren't scored as join keys. WHEREandORDER BYare matched as whole words when collecting their columns. InSELECT nowhere FROM w WHERE ..., theWHEREinsidenowherewas taken for the clause, so no WHERE columns were found and the data-distribution and skew checks skipped them.- Filter selectivity says when it scored only one of a column's values. Only a column's first literal is looked up (
status = 'a' OR status = 'b'checks'a'); the detail now notes "has 2 filter values — selectivity estimated from the first value only". - A CTE body is not a nested subquery.
WITH c AS (SELECT ...)lost 2 points with the advice to use a CTE. A subquery inside a CTE still counts. - A String column is penalized only when the query treats it as a number. Every String column in a comparison lost 3 points, so
WHERE name = 'Bob'was told to castname. Now only a String column compared with a numeric literal is penalized. --duckdbhandles column references qualified by a table name (SELECT data.id FROM data JOIN ...). Table names were rewritten toread_csv_auto(...)in the SQL text, so DuckDB rejected the query. Each input is now registered as a view under its table name and_t_Nalias. A bare table name that isn't a plain identifier (FROM nyc-311, asdescribegptwrites it when scoring) is quoted for DuckDB, so it still works.- An index is found for any file extension. Only
<stem>.csv.idxwas checked, sodata.tsvwas reported as unindexed even afterqsv index data.tsv. The suggestion to runqsv indexnow says what the index is for: it speeds up building the stats and frequency caches, but Polars and DuckDB don't use it for the query. It is no longer shown for stdin input, which is a temp copy deleted on exit. - The "full table scan in query plan" check is removed. It looked for
FULL SCANorTABLE SCAN, which neither Polars (Csv SCAN) nor DuckDB (READ_CSV_AUTO) prints, so it never applied. The plan is still shown in the report, and the USAGE text no longer lists it as a scoring factor.
- Join keys are found when
profile --validate --no-projectionwarns that--validateis ignored.--validatechecks the projection block, so with--no-projectionit ran nothing (neither the schema check nor theqsv validatepass) and exited 0 without a word, even with--strict. To check the file alone, runqsv validate.profile: an--initial-contextoverride that cannot be applied is reported, not silently dropped (#5025, fixes #4991). Before, the run exited 0, passed--strict, and left no trace of the missing value. Each such override now gets a Requiredprojection_warningsentry (on stderr with--no-projection) naming the key and the reason, and fails--strict:- a key that is not a JSON Pointer (doesn't start with
/); - an array index out of range, e.g.
/projection/dataset/1/...on a one-Dataset catalog; - a non-numeric token inside an array, e.g.
/projection/dataset/titlewith--catalog; - a
-with no array to append to. Before, a-under a missing or object parent wrote a"-"key where an array was meant.
- a key that is not a JSON Pointer (doesn't start with
sqlp --format avrowrites every result type (#5024, fixes #5020). Polars' Avro writer rejected a lot of result types, and any one of them failed the whole write withnot yet implemented: write ... to avro:- Rejected types:
TINYINT/SMALLINT, every unsigned integer,TIME, intervals,TIMESTAMPTZandTIMESTAMP(9). - After
--cache-schema: the cached schema makes every non-negative integer column unsigned, so a single--cache-schemarun broke every later Avro export of that file. - Now: small and unsigned integers become int or long (a value above 2⁶³−1 is an error), zoned timestamps become UTC timestamps (the same instant), nanosecond timestamps become microseconds, and
TIMEand durations become strings (HH:MM:SS[.fraction]and ISO 8601), including inside lists. - Still not converted: struct columns from nested Parquet/Arrow input.
- Rejected types:
- security: the bundled SQLite is updated to 3.54.1 (#5022, RUSTSEC-2026-0459, CVE-2026-119375). It's used by
to sqlite. Older SQLite could return uninitialized memory when opening a database next to a crafted-walfile written by someone else. The fix comes fromrusqlite0.41 /libsqlite3-sys0.39, through thecsvs_convertfork. sample:--cluster,--weighted,--systematicand fractional sample sizes work from stdin (#5003, fixes #4999). They returned only the header row, because they read the input twice (a row count or a second pass) and stdin can only be read once. For those methods, stdin is now copied to a temporary file first, which needs temp-directory space for the whole input.--bernoulli,--stratified,--timeseries, and integer sizes for the default method,--varoptand--mergeable-reservoirstill stream stdin.cat rows/rowskey/columnsread piped stdin when no input is given (#5005). Before,... | qsv cat rowsfailed with "No data on stdin" unless you passed-. As withheaders,catwith no input in an interactive terminal now waits for input. With an empty or closed stdin (cron, CI,xargs), it still fails with "No data on stdin", as in 28.0.0 (#5007), so a script whose file list comes up empty still gets an error.cat rowskeywrites nothing for empty input (#5008). It wrote a lone""header line for an empty file or an empty-stdin, whererowsandcolumnswrite nothing. With--group, the grouping column's header is still written.applydp --batch 0works from stdin (#5005). It counted rows first, which consumed stdin and left only the header; it now reads stdin in one batch, aspydoes.geocode --batch 0no longer drops rows from piped stdin (#5009). Counting rows to size the batch consumed the stream, so rows past the first 128 KiB were silently lost, with exit 0 (also in 28.0.0). It now reads stdin in one batch without counting, as thecensusandgeoclientback ends already did.template: legacy time zone aliases such as"EST"and"US/Eastern"work on every system (#5010, via minijinja#1008).datetimeformat(tz=...)failed with "unknown timezone" where the OS time zone database lacks them, e.g. Ubuntu 26.04 withouttzdata-legacy. qsv now bundles the time zone database as a fallback (about 200 KiB); OS data still takes precedence.input --skip-lastlinesworks from stdin (#5012). It counted the rows first, which consumed stdin, so it wrote no rows (exit 0, also in 28.0.0). Stdin is now saved to a temp file when the flag is set.partitionandsplit --chunkswork with-for stdin (#5012). Only stdin with no input argument was saved to a temp file, so with-partitionwrote no files andsplit --chunkswrote a single chunk, both exit 0, or failed with a CSV error on larger input (also in 28.0.0).searchsetno longer fails on large piped input (#5012). It always counted rows with a second reader, which consumed stdin past the first ~64 KiB (also in 28.0.0). It now counts only for the progress bar, and--jsonreports the rows it read.frequencyshows<ALL_UNIQUE>for all-unique columns from stdin (#5012). Only--jsonoutput saved stdin to a temp file, so CSV output listed the column's values instead (also in 28.0.0). Stdin is now always saved to a temp file.sniff -andextsort -read stdin (#5012).-was treated as a file path ("No such file or directory"); omitting the input already worked.to: clearer error when no input is given (#5005). It said "No data on stdin" even when data was piped. It now says no input CSV was given and to use-to read stdin.- tests:
template_contrib_functionsno longer depends on the legacyESTzone alias (#5002).ubuntu-latestis now Ubuntu 26.04, which ships aliases such asESTandUS/Easternonly in the optionaltzdata-legacypackage, so the test failed on every Linux run. It now usesEtc/GMT+5(the same fixed UTC−5 offset). If you usedatetimeformat(tz=...)intemplate, prefer canonical IANA names for the same reason.
CI/Actions
- The
scoresql --duckdbtests run in CI (#5029). They look forduckdbonPATHand were skipped in every job, since no workflow installed it. The macOS Polars workflow now does. joinp_ignore_case_maintain_order_leftno longer asserts the order of tied matches (#5023).--maintain-order leftorders only by the left row. After the Polarsa598c51bump, the test failed about a third of the time. It now checks the left order exactly and the matched rows as a set.- qsvlite and qsvdp build without dead-code warnings (#5040). Items those binaries never use are now gated like their callers, and the full
qsvandqsvmcpbuilds compile exactly as before.
Full Changelog: 28.0.0...29.0.0