EduFlow
SQL for Analysts
0%

Section 1 · Querying with Intent

Working with Dates in SQLite

18 min3 min readSQL exercise

Lesson at a glance

Filter, group and measure time in SQLite using ISO date text, half-open date ranges, strftime() for date parts and julianday() for durations, without losing the last day of a period.

  1. 1Dates are text, and that’s fine
  2. 2Pulling dates apart
  3. 3Rolling windows and weekdays
  4. 4Measuring durations

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Mark Evans, VP Operations, is preparing a November review. He wants two numbers: how many orders Cartwheel took in November 2025, and how quickly support resolved tickets. Both are date questions, and SQLite handles dates differently from most databases. Once you see how, it’s simple and predictable.

Dates are text, and that’s fine

SQLite has no separate date type. Cartwheel stores order_date as ISO-8601 text: '2025-11-28 19:55:30'. Because the format runs from largest unit to smallest, alphabetical order is chronological order. Comparing strings compares moments in time, and an index on the column works for date ranges.

Here’s the obvious November query:

SQL
SELECT COUNT(*) AS november_orders
FROM orders
WHERE order_date BETWEEN '2025-11-01' AND '2025-11-30';

336 orders. It’s wrong by 11.

A timeline from 29 November to 1 December. BETWEEN '2025-11-01' AND '2025-11-30' stops at midnight at the start of 30 November, missing an order at 19:55 that day. The half-open range, less than '2025-12-01', covers all of 30 November. 11-29 11-30 00:00 12-01 00:00 order 19:55 BETWEEN … AND '2025-11-30' >= '2025-11-01' AND < '2025-12-01' time runs right
A date-only upper bound means midnight at the start of the last day

'2025-11-30' is shorter than '2025-11-30 19:55:30', and a shorter string that matches the start sorts first. So the upper bound is effectively midnight at the start of 30 November, and everything later that day falls outside. The fix is a half-open range: include the start, exclude the start of the next period.

SQL
SELECT COUNT(*) AS november_orders
FROM orders
WHERE order_date >= '2025-11-01'
  AND order_date <  '2025-12-01';

347, the real number. On the busiest days of the Black Friday weekend, those 11 missing orders would be a visible hole in Mark’s chart.

Pulling dates apart

strftime(format, value) returns parts of a date as text:

Format Returns Example
'%Y-%m' year and month '2025-11'
'%Y' year '2025'
'%w' weekday, 0 = Sunday '5'
'%H' hour, 00–23 '19'

'%Y-%m' is the workhorse: it turns a timestamp into a month label you can group by in the next lesson. Because the result is text, compare it with text: strftime('%w', order_date) = '0' finds Sunday orders, while = 0 finds nothing.

date() and datetime() normalise a value and accept modifiers, which make period boundaries easy:

SQL
SELECT
  date('2025-11-28 19:55:30')                                   AS just_the_date,
  date('2025-11-28', 'start of month')                          AS month_start,
  date('2025-11-28', 'start of month', '+1 month')              AS next_month_start,
  date('2025-11-28', 'start of month', '+1 month', '-1 day')    AS month_end,
  date('2025-11-28', '-6 days')                                 AS week_ago;

Modifiers apply left to right. 'start of month', '+1 month' is exactly the exclusive upper bound a half-open range needs.

Rolling windows and weekdays

“Orders in the last 30 days” is a rolling window, and it hides an off-by-one question: does “last 30 days” as of 31 December include the 31st? Usually yes, which means the window starts on 2 December, 29 days earlier. Build both bounds from the anchor date so the window moves with it:

SQL
SELECT COUNT(*) AS orders_last_30_days
FROM orders
WHERE order_date >= date('2025-12-31', '-29 days')
  AND order_date <  date('2025-12-31', '+1 day');

189 orders. The upper bound is the start of the day after the anchor, the same half-open idea as before. Write the window’s first and last day in a comment above the query; whoever reviews it will check exactly that.

Weekday analysis combines strftime('%w', ...) with the CASE expressions from the previous lesson. Mapping ‘0’ and ‘6’ to weekend labels is a common first cut when marketing asks whether weekend campaigns are worth it:

SQL
SELECT
  order_id,
  order_date,
  CASE strftime('%w', order_date)
    WHEN '0' THEN 'weekend'
    WHEN '6' THEN 'weekend'
    ELSE 'weekday'
  END AS day_type
FROM orders
ORDER BY order_date DESC
LIMIT 5;

This CASE uses the simple form, CASE value WHEN x THEN ..., which compares one value against a list. It’s handy for mappings like this one; use the searched form, CASE WHEN condition THEN ..., whenever a branch needs a real condition.

Measuring durations

julianday() converts a date to a number of days (with fractions for the time). Subtract two and you have a duration in days; multiply by 24 for hours. Here are December’s urgent tickets with their resolution times:

SQL
SELECT
  ticket_id,
  opened_at,
  closed_at,
  ROUND((julianday(closed_at) - julianday(opened_at)) * 24, 1) AS hours_to_close
FROM support_tickets
WHERE priority = 'urgent'
  AND opened_at >= '2025-12-01'
ORDER BY opened_at;

Ticket 691 has no closed_at, so its duration is NULL. That’s correct: it isn’t zero hours, it’s unfinished, and any average you compute later will skip it rather than flatter the team.

You can now filter and measure time. Mark’s other question, average resolution time per priority, needs one more idea: collapsing many rows into one per group. That’s next.

Key takeaways

  • SQLite stores dates as ISO-8601 text, which sorts chronologically, so plain string comparison works as a date filter.
  • Filter a period with a half-open range (>= start AND < next start); BETWEEN with a date-only upper bound drops everything after midnight on the last day.
  • strftime() extracts parts like the month (‘%Y-%m’), weekday (‘%w’, 0 = Sunday) or hour (‘%H’) as text.
  • julianday(later) - julianday(earlier) gives a duration in fractional days; multiply by 24 for hours.
  • Anchor “as of” calculations on a fixed date instead of ‘now’ so the same query gives the same answer tomorrow.

Further reading

Check your understanding

4 questions

Question 1 of 4

order_date holds values like ‘2025-11-30 19:55:30’. Which filter returns every order placed in November 2025?

Question 2 of 4

What does strftime('%m', '2025-03-09 14:00:00') return?

Question 3 of 4

A ticket opened at ‘2025-12-04 17:56:18’ and closed at ‘2025-12-05 01:56:18’. What does (julianday(closed_at) - julianday(opened_at)) * 24 return?

Question 4 of 4

You compute customer tenure with julianday('now') - julianday(signup_date). Why might a colleague object?

Practice

SQL exercise

November resolution times

Mark wants every ticket opened in November 2025 with its resolution time in hours.

Return ticket_id, priority and hours_to_close (rounded to 1 decimal; leave it NULL for tickets that are still open), sorted by ticket_id. Order matters.

The starter has two bugs: one drops tickets, the other gets the unit wrong.

Tables
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- November 2025 tickets and how long they took to close
SELECT
  ticket_id,
  priority,
  ROUND(julianday(closed_at) - julianday(opened_at), 1) AS hours_to_close
FROM support_tickets
WHERE opened_at BETWEEN '2025-11-01' AND '2025-11-30'
ORDER BY ticket_id;Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.