How to view journalctl logs for a specific time range

Introduction

You know roughly when it broke. You want the journal for those ten minutes and nothing else.

--since and --until do that. Both are easy to use and easy to get subtly wrong, and the wrong version returns fewer lines rather than an error. This guide covers the syntax, the three traps, and what to do when the window you want is older than the journal.

The short answer

journalctl --since "2026-08-28 14:00" --until "2026-08-28 14:10"

Entries print oldest first. Add -u nginx to narrow it to one unit, and --no-pager if you are piping it somewhere.

Relative times

Anything prefixed with - counts backwards from now.

journalctl --since "-1h"           # the last hour
journalctl --since "-30min"        # the last thirty minutes
journalctl --since "-2 days"       # the last two days

The spelled-out form works too, and reads better in a script someone else will have to maintain:

journalctl --since "1 hour ago"
journalctl --since "10 min ago" --until "5 min ago"

That last one is the shape you want during an incident. A window with both ends pinned stays the same window while you keep re-running it, which a bare --since "-10min" does not.

Absolute times

The full form is YYYY-MM-DD HH:MM:SS. You can leave parts off, and what journalctl fills in is where people get caught.

What you type What journalctl uses
2026-08-28 14:37:02 exactly that
2026-08-28 14:37 14:37:00 — seconds default to :00
2026-08-28 2026-08-28 00:00:00 — midnight
14:37 14:37:00 today

The midnight trap

A bare date means midnight at the start of that day. So this command does not do what it looks like it does:

journalctl --since 2026-08-27 --until 2026-08-28

It gives you all of the 27th and none of the 28th. --until 2026-08-28 stops at 00:00:00 on the 28th, before anything happened. If you want both days, name the end explicitly:

journalctl --since 2026-08-27 --until "2026-08-28 23:59:59"

yesterday, today, now

journalctl understands three words, and they are all midnight:

  • yesterday — 00:00:00 of the day before today
  • today — 00:00:00 of today
  • tomorrow — 00:00:00 of the day after today

now is the exception. It means the current time.

So journalctl --since yesterday gives you yesterday and today, which surprises people who expected one day. For only yesterday, pin both ends:

journalctl --since yesterday --until today

Timezones

An absolute time with no timezone on it is read in the machine’s local time. That is fine on one server and a problem across several, because a window that means 14:00 on a box in Berlin means something else on a box in Chicago.

Append the timezone to say what you mean:

journalctl --since "2026-08-28 14:00 UTC" --until "2026-08-28 14:10 UTC"

The literal string UTC works, and so does an IANA name like Europe/Berlin. Add --utc to print the results in UTC as well, so the timestamps you read match the window you asked for:

journalctl --since "2026-08-28 14:00 UTC" --utc -o short-iso

-o short-iso gives sortable RFC 3339 timestamps. The default output format omits the year, which is not what you want in a window that spans a New Year or in anything you paste into a ticket.

Combining the window with other filters

--since and --until are just two more matches. Everything ANDs together.

journalctl -u nginx --since "-1h" -p err
journalctl -k --since "2026-08-28 03:00" --until "2026-08-28 04:00"
journalctl -u postgresql --since today -g "deadlock"

One combination behaves differently from the rest. -n limits the count after the window is applied, and it takes the newest entries in that window, not the oldest:

journalctl --since "-1h" -n 20      # the newest 20 of the last hour
journalctl --since "-1h" -n +20     # the oldest 20 of the last hour

The + prefix is the whole difference. Without an argument, -n defaults to 10.

When the window is older than the journal

Two things quietly limit how far back you can go.

The journal may not be persistent. If /var/log/journal does not exist, systemd’s default Storage=auto stores the journal in /run/log/journal, which is memory. A reboot empties it. --since "3 days ago" on that machine returns whatever has happened since the last boot and no error at all.

Check what you have:

journalctl --list-boots

One line means one boot’s worth of history. To make it persistent:

sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald

The journal is capped even when it is persistent. systemd vacuums old entries to stay under SystemMaxUse=, and it does not ask first. See clearing journalctl logs and limiting journal disk usage for the caps and how to read the current usage.

Use boots as the anchor instead

When you are chasing something around a restart, boot numbers beat clock times.

journalctl --list-boots     # -1, 0 and so on, with the times of each
journalctl -b               # this boot
journalctl -b -1            # the previous boot
journalctl -b -1 -p err     # the errors that ended it

Finding out why a Linux server rebooted goes through that in full, including what to do when there is no previous boot listed.

The window you want is on a different machine

This is where the single-host commands stop.

An incident at 14:03 rarely stays on one box. You want the same ten minutes from the proxy, the app and the database, lined up in one list. On separate hosts that means three SSH sessions, three journals and three local clocks that may not agree — and the machine you most want to read is sometimes the one that stopped responding.

Shipping the journals to one place turns the window into a WHERE clause.

Querying a time range once the logs have landed

Central Logging stores each line in SQLite, so the window becomes SQL. There are two clocks available and they are not the same clock.

timestamp is when the server received the line. It is a Unix timestamp in seconds, and it is the right column for “what arrived recently”:

SELECT datetime(timestamp, 'unixepoch') AS received,
       source,
       msg
FROM logs
WHERE timestamp BETWEEN CAST(strftime('%s', '2026-08-28 14:00:00') AS INTEGER)
                    AND CAST(strftime('%s', '2026-08-28 14:10:00') AS INTEGER)
ORDER BY timestamp;

datetime(..., 'unixepoch') renders UTC. Add 'localtime' if you would rather read it in the server’s zone:

SELECT datetime(timestamp, 'unixepoch', 'localtime') AS received FROM logs;

__REALTIME_TIMESTAMP is when the event happened. journald records it, the agent ships it, and it survives the trip. Use it when the two could differ — after an outage a backlog arrives all at once and sorts by receipt time as though it just occurred. It is in microseconds, so divide by a million:

SELECT datetime(CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000, 'unixepoch') AS at,
       json_extract(msg, '$._HOSTNAME') AS host,
       json_extract(msg, '$.MESSAGE')   AS message
FROM logs
WHERE json_valid(msg)
  AND CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000
      BETWEEN CAST(strftime('%s', '2026-08-28 14:00:00') AS INTEGER)
          AND CAST(strftime('%s', '2026-08-28 14:10:00') AS INTEGER)
ORDER BY at;

That query is one line longer than it looks like it needs to be, for two reasons worth knowing.

Keep the json_valid guard

json_extract raises an error the first time it meets a line that is not JSON. One plain-text line in the source is enough to stop the whole query. WHERE json_valid(msg) AND ... protects the terms after it, because SQLite short-circuits AND.

It does not protect the SELECT list. Those expressions run on every row the WHERE admits, so the guard has to eliminate the bad rows, not just sit in front of them.

Cast both sides of the comparison

strftime('%s', ...) returns text, not a number. SQLite sorts every text value above every integer regardless of what the text says, so comparing an extracted number against an uncast strftime matches nothing — and raises no error:

-- returns zero rows, silently
WHERE CAST(json_extract(msg, '$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000
      BETWEEN strftime('%s', 'now', '-1 hour') AND strftime('%s', 'now')

The version against the timestamp column happens to work without the cast, because that column has integer affinity and SQLite converts the text operand to match. An extracted value has no column affinity to lean on.

Cast both sides every time. It costs nothing and removes the question.

Counting by hour

Once the window is in SQL, narrowing it is arithmetic:

SELECT strftime('%Y-%m-%d %H:00', timestamp, 'unixepoch') AS hour,
       count(*) AS lines
FROM logs
GROUP BY hour
ORDER BY hour;

That is usually how you find the ten minutes you should have been asking about.

Where to go next

💌 Get notified on new features and updates

Only sent when a new version is released. Nothing else.