{"slug": "my-last-hour-query-returned-1252-rows-the-real-answer-was-68", "title": "My \"Last Hour\" Query Returned 1,252 Rows. The Real Answer Was 68.", "summary": "A developer behind the independent project AI Change Watch found that a SQLite freshness query returned 1,252 crawl runs for the last hour when the true count was 68, because SQLite's datetime() emits a space at position 11 while stored ISO 8601 timestamps use a 'T' (0x54 vs 0x20), making the comparison a byte-wise text comparison that treats any same-day row as newer than the cutoff. The shipped application code was unaffected since it builds bounds in JavaScript with toISOString(), leaving the bug confined to hand-written operational queries; the fix is to normalize the cutoff with strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hour').", "body_md": "I run [**AI Change Watch**](https://aichangewatch.com/?src=devto), a small independent project that\n\ncrawls what 15 AI vendors publish about their own models — deprecation tables, lifecycle pages, pricing\n\nand SDK releases — and records every time one of them changes.\n\nEvery so often I check whether the crawler is still alive by counting recent runs. The query is the\n\nobvious one:\n\n```\nSELECT COUNT(*) FROM crawl_runs\nWHERE started_at > datetime('now', '-1 hour');\n```\n\nIt returned **1,252**. The true number of runs in that hour was **68**.\n\nNo error. No warning. A plausible-looking integer, roughly eighteen times too large, from a query that\n\nreads correctly in review.\n\n`crawl_runs.started_at` is written by application code, as ISO 8601:\n\n```\n2026-08-24T17:40:41.965Z\n```\n\nSQLite's `datetime()` returns its own format, which uses a **space** instead of `T` and has no\n\nfractional part or zone suffix:\n\n```\nsqlite> SELECT datetime('now', '-1 hour');\n2026-08-24 16:54:52\n```\n\nBoth are strings. SQLite has no dedicated date type — dates are TEXT, REAL or INTEGER by convention —\n\nso `>` here is a **text comparison**, byte by byte.\n\nAnd that is where it goes wrong, at exactly one character:\n\n| position 11 | value | byte | \n|---|---|---|\n| stored | `T` | `0x54` | \n| cutoff | `' '` | `0x20` | \n\n`T` sorts above a space. So for every row whose **date part is the same day as the cutoff**, the\n\ncomparison stops at position 11, finds `0x54 > 0x20`, and answers *greater* — no matter what time it\n\nactually is. A run from 00:03 that morning is \"in the last hour.\"\n\nThe fix is to make the cutoff the same shape as the column:\n\n```\n-- wrong: 1252 rows\nWHERE started_at > datetime('now', '-1 hour')\n\n-- right: 68 rows\nWHERE started_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hour')\n```\n\nBecause both of its failure modes are comfortable.\n\n**For freshness checks it fails loud.** The number comes out too big, which means a stalled crawler\n\nstill looks busy. This is the dangerous direction — it is precisely the check whose whole job is to tell\n\nyou something stopped, and it is biased toward saying everything is fine.\n\n**For windowed audits it fails safe.** `-1 day` or `-7 day` pulls in the entire boundary day, so a\n\nreview window is wider than you stated, never narrower. Nothing is missed; you just quietly reviewed\n\nmore than you meant to. Nobody notices being handed extra.\n\nNeither shows up in the output. You do not get a type error, a coercion warning, or an empty result that\n\nmakes you look twice. You get rows, and they are real rows, and they are formatted like the ones you\n\nwanted.\n\nHere is the part I found most interesting once I went looking. **My shipped code was never affected.**\n\nNothing under `src/` or `web/` calls SQLite's `datetime('now')` or `julianday('now')` at all. Every\n\nbound in the application is built in JavaScript:\n\n``` js\nconst since = new Date(Date.now() - 86_400_000).toISOString();\n// '2026-08-23T17:40:41.965Z'  — same shape as the column\n```\n\nWhich emits the identical `T`/` Z` form, so the comparison is like-for-like everywhere it ships.\n\nThe bug lived entirely in **hand-written operational queries** — the ones I type into a console to\n\nanswer a question right now. That is the least-examined code in any project:\n\nSeveral \"runs in the last hour\" figures I had quoted before finding this were inflated by it. The\n\napplication was healthy the whole time; the instrument was wrong.\n\nTwo escape hatches, in the order I'd reach for them.\n\n**Normalise the cutoff, not the column.** Rewriting stored data is a migration; rewriting a cutoff is a\n\nline. `strftime` above is the clean version, but if you already have `datetime()` calls scattered through\n\na script, patching them in place also works:\n\n```\nWHERE started_at > replace(datetime('now', '-1 hour'), ' ', 'T') || 'Z'\n```\n\nI prefer `strftime` — it states the format it produces instead of repairing one — but this is fine when\n\nyou are editing twenty ad-hoc queries and want a mechanical change.\n\n**Or stop storing dates as text.** SQLite has no date type; the documentation offers three conventions —\n\nISO-8601 TEXT, Julian day as REAL, and Unix epoch as INTEGER — and the whole class of bug in this post\n\nonly exists in the first one. Integers compare as numbers, so there is no format to disagree about:\n\n```\nWHERE started_at_ms > (unixepoch('now', '-1 hour') * 1000)\n```\n\nThe trade is readability. `1787143241965` in a console tells you nothing, and every query you write by\n\nhand now needs a conversion to be legible. For a table I mostly read by eye I kept the text column and\n\nfixed the cutoffs. For one I only ever compared, I would not.\n\nWorth knowing which trade you made, rather than discovering it at position 11 of a string.\n\n**Grep for the mixed forms.** If both of these return hits in the same project, you have the ingredients:\n\n```\nrg \"datetime\\('now'|julianday\\('now'\"     # SQL-side clocks\nrg \"toISOString\\(\\)\"                      # JS-side timestamps\n```\n\nThe bug is not either one. It's a comparison with one of each on opposite sides.\n\n**Cross-check any window with a grouping.** This is the cheap, general test, and it needs no knowledge of\n\nthe storage format:\n\n```\nSELECT substr(started_at, 1, 13) AS hour, COUNT(*)\nFROM crawl_runs\nGROUP BY hour ORDER BY hour DESC LIMIT 5;\n```\n\nIf the hourly buckets don't sum to what your windowed query claimed, the comparison is the reason. That\n\nis how I found the 68.\n\n**Look at your columns before you compare them.** One `SELECT started_at FROM crawl_runs LIMIT 1` would\n\nhave shown me the `T` at any point in the preceding months.\n\nThe general form of this, which is not really about SQLite: **a comparison between two values that were produced by different systems is a format assumption, whether or not you wrote it down.** Text\n\n*Found 2026-08-24, fixed the same day. The tracker this came out of is at\n[aichangewatch.com](https://aichangewatch.com/?src=devto) — it records what AI vendors change in their\nown docs, which involves a lot of timestamps that have to be comparable across sources.*", "url": "https://wpnews.pro/news/my-last-hour-query-returned-1252-rows-the-real-answer-was-68", "canonical_source": "https://dev.to/ai_changewatch/my-last-hour-query-returned-1252-rows-the-real-answer-was-68-5fgh", "published_at": "2026-09-11 12:00:00+00:00", "updated_at": "2026-09-11 12:10:35.997214+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["AI Change Watch", "SQLite", "JavaScript"], "alternates": {"html": "https://wpnews.pro/news/my-last-hour-query-returned-1252-rows-the-real-answer-was-68", "markdown": "https://wpnews.pro/news/my-last-hour-query-returned-1252-rows-the-real-answer-was-68.md", "text": "https://wpnews.pro/news/my-last-hour-query-returned-1252-rows-the-real-answer-was-68.txt", "jsonld": "https://wpnews.pro/news/my-last-hour-query-returned-1252-rows-the-real-answer-was-68.jsonld"}}