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.

RepresentationStorageExample
ISO 8601 textTEXT'2026-09-15 14:30:00'
Unix secondsINTEGER1789482600
Julian dayREAL2461299.104166667
ISO textUnix integerJulian real
Readable in a dump or a SELECTYesNoNo
Sorts and compares correctly as storedYes, lexicographicallyYesYes
Bytes per value19-234-88
Sub-second precisionYes, with .SSSNeeds milliseconds and careInherent, lossy
RangeAny yearFine until 2038 only for 32-bit clients4714 BC onward
Works with date()/strftime() directlyYesNeeds '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. Use INTEGER Unix 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

FunctionReturns
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.

ModifierEffect
'+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

CodeValue
%Y %m %d4-digit year · month 01-12 · day 01-31
%H %M %SHour 00-24 · minute 00-59 · second 00-59
%fSS.SSS, seconds with milliseconds
%F %T %R%Y-%m-%d · %H:%M:%S · %H:%M. 3.44+
%jDay of the year, 001-366
%wDay of the week, 0-6 with Sunday as 0
%uDay of the week, 1-7 with Monday as 1. 3.44+
%WWeek of the year, 00-53, weeks start Monday
%G %VISO 8601 year and week number. 3.46+
%sUnix timestamp as text
%JJulian day as text
%e %k %I %p %PSpace-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)
KeywordFormat
CURRENT_DATEYYYY-MM-DD
CURRENT_TIMEHH:MM:SS
CURRENT_TIMESTAMPYYYY-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.