SQLite Cheatsheet
Dates and Times
Use this SQLite reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
There is no date type
SQLite has no DATE, TIME, TIMESTAMP, or DATETIME storage class. A column declared
DATETIME gets NUMERIC affinity and stores whatever you put in it. You pick one of three
representations and stay consistent.
| Representation | Storage | Example |
|---|---|---|
| ISO 8601 text | TEXT | '2026-09-15 14:30:00' |
| Unix seconds | INTEGER | 1789482600 |
| Julian day | REAL | 2461299.104166667 |
| ISO text | Unix integer | Julian real | |
|---|---|---|---|
Readable in a dump or a SELECT | Yes | No | No |
| Sorts and compares correctly as stored | Yes, lexicographically | Yes | Yes |
| Bytes per value | 19-23 | 4-8 | 8 |
| Sub-second precision | Yes, with .SSS | Needs milliseconds and care | Inherent, lossy |
| Range | Any year | Fine until 2038 only for 32-bit clients | 4714 BC onward |
Works with date()/strftime() directly | Yes | Needs 'unixepoch' | Yes |
Default to ISO 8601 text in UTC,
YYYY-MM-DD HH:MM:SS. It sorts correctly with a plain string comparison, every date function accepts it with no modifier, it is readable in a.dump, and it survives a database that outlives 32-bit time. UseINTEGERUnix seconds when storage size or arithmetic speed genuinely dominates.
CREATE TABLE events( id INTEGER PRIMARY KEY, at TEXT NOT NULL DEFAULT (datetime('now')), -- UTC, 'YYYY-MM-DD HH:MM:SS' at_ms TEXT, -- datetime('now','subsec') at_unix INTEGER NOT NULL DEFAULT (unixepoch()), CHECK(at IS strftime('%Y-%m-%d %H:%M:%S', at)) -- rejects malformed text ) STRICT;
That CHECK is a cheap way to keep garbage out, since strftime returns NULL on an
unparseable value and IS compares NULLs as equal.
The functions
| Function | Returns |
|---|---|
date(t, mods...) | YYYY-MM-DD |
time(t, mods...) | HH:MM:SS |
datetime(t, mods...) | YYYY-MM-DD HH:MM:SS |
julianday(t, mods...) | A REAL Julian day number |
unixepoch(t, mods...) | An INTEGER of seconds since 1970. 3.38+ |
strftime(fmt, t, mods...) | Formatted text, the general case |
timediff(a, b) | An ISO duration string, a minus b. 3.43+ |
SELECT date('now'), time('now'), datetime('now'); -- 2026-09-15|18:42:01|2026-09-15 18:42:01 SELECT unixepoch('now'), julianday('now'); -- 1789497721|2461299.279178 SELECT datetime(1789497721, 'unixepoch'); -- 2026-09-15 18:42:01 SELECT datetime('now', 'subsec'); -- 2026-09-15 18:42:01.482 3.42+ SELECT unixepoch('now','subsec'); -- 1789497721.482
Accepted time-value formats, all of which the functions parse:
YYYY-MM-DD YYYY-MM-DD HH:MM YYYY-MM-DD HH:MM:SS YYYY-MM-DD HH:MM:SS.SSS YYYY-MM-DDTHH:MM:SS (a literal T also works) HH:MM / HH:MM:SS (date defaults to 2000-01-01) now DDDDDDDDDD (a raw Julian day number)
A trailing Z or +HH:MM offset is understood and normalized to UTC. Anything else
returns NULL, not an error, which is how a bad date silently propagates.
SELECT date('2026-13-45'); -- (NULL) SELECT date('15/09/2026'); -- (NULL), no locale parsing at all SELECT datetime('2026-09-15T14:30:00+02:00'); -- 2026-09-15 12:30:00
Modifiers
Modifiers apply left to right, and order changes the result.
| Modifier | Effect |
|---|---|
'+N days' · '-N days' | Also hours, minutes, seconds, months, years |
'NNN.NNNN days' | Fractional amounts are allowed |
'start of day' | Truncate to midnight |
'start of month' · 'start of year' | Truncate |
'weekday N' | Advance to the next weekday N, 0 is Sunday. Stays put if already there |
'unixepoch' | Interpret the value as Unix seconds. Must come first |
'julianday' | Interpret the value as a Julian day. 3.43+ |
'auto' | Guess between Julian day and Unix epoch. 3.43+ |
'localtime' | Convert from UTC to the system time zone |
'utc' | Convert from the system time zone to UTC |
'subsec' · 'subsecond' | Keep milliseconds. 3.42+ |
'floor' · 'ceiling' | How '+N months' handles an overflowing day. ceiling is the default. 3.46+ |
SELECT date('now','start of month'); -- 2026-09-01 SELECT date('now','start of month','+1 month','-1 day'); -- end of this month SELECT date('now','weekday 1'); -- next Monday, or today if Monday SELECT date('now','-6 days','weekday 1'); -- the Monday of this week SELECT datetime('now','start of day','+18 hours');-- 18:00 today SELECT date('now','+1 year','-1 day'); SELECT datetime(1789497721,'unixepoch','localtime');
The order trap, worth reading twice:
SELECT date('2026-09-15','+1 month','start of month'); -- 2026-10-01 SELECT date('2026-09-15','start of month','+1 month'); -- 2026-10-01 SELECT date('2026-01-31','+1 month'); -- 2026-03-03
That last one is not a bug. SQLite adds 1 to the month, producing February 31, then normalizes the overflow into March 3. Every engine handles this differently and SQLite's choice is to carry. From 3.46 you can ask for clamping instead.
SELECT date('2026-01-31','+1 month','floor'); -- 2026-02-28 3.46+ SELECT date('2026-01-31','+1 month','ceiling'); -- 2026-03-03 3.46+ SELECT date('2026-01-31','+1 month'); -- 2026-03-03, ceiling is the default -- Portable clamp: go to the start of the month, add, then take the day SELECT date('2026-01-31','start of month','+1 month', '+' || (CAST(strftime('%d','2026-01-31') AS INTEGER)-1) || ' days');
strftime formats
| Code | Value |
|---|---|
%Y %m %d | 4-digit year · month 01-12 · day 01-31 |
%H %M %S | Hour 00-24 · minute 00-59 · second 00-59 |
%f | SS.SSS, seconds with milliseconds |
%F %T %R | %Y-%m-%d · %H:%M:%S · %H:%M. 3.44+ |
%j | Day of the year, 001-366 |
%w | Day of the week, 0-6 with Sunday as 0 |
%u | Day of the week, 1-7 with Monday as 1. 3.44+ |
%W | Week of the year, 00-53, weeks start Monday |
%G %V | ISO 8601 year and week number. 3.46+ |
%s | Unix timestamp as text |
%J | Julian day as text |
%e %k %I %p %P | Space-padded day · space-padded hour · 12-hour · AM/PM. 3.44+ |
%% | A literal percent |
SELECT strftime('%Y-%m', at) AS month, count(*) FROM events GROUP BY 1; SELECT strftime('%Y-W%W', 'now'); -- 2026-W37 SELECT strftime('%H:%M', 'now', 'localtime'); SELECT strftime('%d/%m/%Y', at) FROM events; -- reformat for display only SELECT strftime('%s','now'); -- '1789497721' as TEXT SELECT unixepoch('now'); -- 1789497721 as INTEGER, 3.38+
There is no %Z and no time zone name anywhere. SQLite knows only UTC and "the system's
local time", with no tz database, so per-user time zones must be handled in the
application or with a lookup table of offsets.
CURRENT_TIMESTAMP is UTC
The three SQL keywords are UTC, always, with no way to change that.
SELECT CURRENT_DATE, CURRENT_TIME, CURRENT_TIMESTAMP; -- 2026-09-15|18:42:01|2026-09-15 18:42:01 SELECT CURRENT_TIMESTAMP = datetime('now'); -- 1 SELECT CURRENT_TIMESTAMP = datetime('now','localtime');-- 0 outside UTC SELECT datetime('now'), datetime('now','localtime'); -- 2026-09-15 18:42:01|2026-09-15 11:42:01 (offset is the host's, not fixed)
| Keyword | Format |
|---|---|
CURRENT_DATE | YYYY-MM-DD |
CURRENT_TIME | HH:MM:SS |
CURRENT_TIMESTAMP | YYYY-MM-DD HH:MM:SS |
Store UTC, convert for display. A DEFAULT CURRENT_TIMESTAMP column is already UTC,
which is what you want, and 'localtime' is a presentation-layer modifier. Note that
'localtime' depends on the server's TZ, so the same query gives different answers on
a laptop and in a container, and it is never safe inside an index expression or a
generated column because the value is not deterministic.
-- Correct: a UTC default created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT (datetime('now')) -- identical -- Wrong: non-deterministic, and rejected in an index or generated column created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
Also note that date('now') is evaluated once per statement, so a multi-row insert
gets one consistent value rather than a drifting one.
Differences and durations
There is no interval type. You compute a difference in a unit you choose.
-- Days between two dates, as a real number SELECT julianday('2026-12-25') - julianday('2026-09-15'); -- 101.0 -- Whole days SELECT CAST(julianday('2026-12-25') - julianday('now') AS INTEGER); -- Seconds, minutes, hours SELECT unixepoch(b) - unixepoch(a) AS seconds, (unixepoch(b) - unixepoch(a)) / 60 AS whole_minutes, (unixepoch(b) - unixepoch(a)) / 3600.0 AS hours FROM spans; -- Whole months, calendar-aware SELECT (strftime('%Y', b) - strftime('%Y', a)) * 12 + (strftime('%m', b) - strftime('%m', a)) AS months FROM spans;
timediff (3.43+) gives a calendar-correct ISO duration string, a minus b.
SELECT timediff('2026-12-25','2026-09-15'); -- +0000-03-10 00:00:00.000 SELECT timediff('2026-09-15','2026-12-25'); -- -0000-03-10 00:00:00.000
The format is ±YYYY-MM-DD HH:MM:SS.SSS, so parse it with substr or strftime rather
than treating it as a number.
Age in whole years, the classic. Comparing the MMDD portions handles the birthday
correctly without special cases.
SELECT (strftime('%Y', 'now') - strftime('%Y', dob)) - (strftime('%m-%d', 'now') < strftime('%m-%d', dob)) AS age FROM people;
The subtraction of a boolean works because SQLite comparisons return 1 or 0, so the expression subtracts one year when this year's birthday has not happened yet.
Recipes
Bucketing for reports. Every one of these is index-friendly only if you have an expression index on the same expression, otherwise it is a scan.
-- Per day, month, year SELECT date(at) AS d, count(*) FROM events GROUP BY 1 ORDER BY 1; SELECT strftime('%Y-%m', at) AS m, count(*) FROM events GROUP BY 1; SELECT strftime('%Y', at) AS y, count(*) FROM events GROUP BY 1; -- Per hour of the day, to find traffic peaks SELECT strftime('%H', at) AS hh, count(*) FROM events GROUP BY 1 ORDER BY 1; -- Per ISO week, Monday-based SELECT date(at, '-6 days', 'weekday 1') AS week_start, count(*) FROM events GROUP BY 1 ORDER BY 1; -- 15-minute buckets, via integer division on the epoch SELECT datetime((unixepoch(at) / 900) * 900, 'unixepoch') AS bucket, count(*) FROM events GROUP BY 1 ORDER BY 1;
Range filters. Write them so the stored column stays bare on the left, which is what
lets an index on at be used.
-- Good: a plain range on the column SELECT * FROM events WHERE at >= '2026-09-01' AND at < '2026-10-01'; SELECT * FROM events WHERE at >= datetime('now','-7 days'); -- Bad: wraps the column, so no index can be used SELECT * FROM events WHERE strftime('%Y-%m', at) = '2026-09'; SELECT * FROM events WHERE date(at) = '2026-09-15'; -- The index-friendly form of that last one SELECT * FROM events WHERE at >= '2026-09-15' AND at < '2026-09-16';
Because ISO 8601 text sorts lexicographically, at >= '2026-09-01' and
at LIKE '2026-09%' both work on a text column, and the range form uses an index.
-- Rows older than 90 days, for a retention job DELETE FROM events WHERE at < datetime('now','-90 days'); -- Expiring sessions, with the comparison on the stored side DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP RETURNING id; -- An expression index when you truly need a bucketed lookup CREATE INDEX idx_events_month ON events(strftime('%Y-%m', at)); -- A generated + indexed day column, cheaper than an expression index to read ALTER TABLE events ADD COLUMN day TEXT AS (date(at)); CREATE INDEX idx_events_day ON events(day);
A generated column is allowed to call date(at) because the argument is a column rather
than 'now', so the expression is deterministic. date('now') inside a generated column
or an index is rejected for exactly that reason.