Claude Code output-format json: log tokens, cost and turns
How to use claude code --output-format json to capture tokens, cache reads, cost and turns per call, store them in SQLite, and handle usage limits.
Run claude -p "your prompt" --output-format json and the CLI prints one JSON object instead of plain text. The object holds the answer in a result field. Next to it are the usage numbers, the turn count and the duration. Parse that object after each call and write it to a log.
Plain claude -p gave me none of those numbers, so I switched every call to --output-format json. Now I log tokens, cache reads, turns and duration per call into SQLite. The rest of this post covers the call, the fields to keep, the table, and how I handle usage limits on a Claude Max plan.
What claude code output format json returns#
The basic call looks like this:
claude -p "Summarize the open TODOs in this repo" --output-format json > out.json
The flag belongs to print mode (-p). The Claude Code headless documentation lists the output formats and the fields in the result. Read it for your installed version, because field names can change between releases.
In the versions I have seen, the object includes these fields:
result: the final text answeris_error: whether the call ended in an errornum_turns: how many turns the run tookduration_msandduration_api_ms: wall time and API timesession_id: the session this call belongs tototal_cost_usd: the cost estimate the CLI reportsusage: an object withinput_tokens,output_tokens,cache_creation_input_tokensandcache_read_input_tokens
Before you write any parser, look at the real output from your own install:
jq 'keys' out.json
jq '.usage' out.json
That takes ten seconds and saves you from coding against a field that your version names differently.
Which fields to log per call#
I log tokens, cache reads, turns and duration for every call. You can add more, but those four answer most of the questions you will have later:
- Tokens (input and output): how big each job is, and which job types are heavy.
- Cache reads: whether repeated context is being served from cache. A job type with high input tokens and zero cache reads is worth a look.
- Turns: how many back-and-forth steps one call took. A call that suddenly takes many more turns than usual is the first sign of a prompt or tool problem.
- Duration: how long the worker was occupied by the call.
I would also store the job name or type, a timestamp, session_id and the is_error flag. Those let you group the numbers and find a bad call afterward. If your CLI version reports a cost field, store it as well. Treat it as the CLI's own estimate and don't rely on it as an invoice.
Storing the numbers in SQLite#
SQLite is enough here. One file, one table, no server to run on a small VPS. The Python sqlite3 module is in the standard library.
import json
import sqlite3
import subprocess
import time
db = sqlite3.connect("calls.db")
db.execute("""
CREATE TABLE IF NOT EXISTS calls (
ts INTEGER,
job TEXT,
session_id TEXT,
is_error INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
cache_read_tokens INTEGER,
cache_create_tokens INTEGER,
num_turns INTEGER,
duration_ms INTEGER,
cost_usd REAL
)""")
def run(job, prompt):
p = subprocess.run(
["claude", "-p", prompt, "--output-format", "json"],
capture_output=True, text=True,
)
data = json.loads(p.stdout)
u = data.get("usage", {})
db.execute(
"INSERT INTO calls VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(int(time.time()), job, data.get("session_id"),
int(bool(data.get("is_error"))),
u.get("input_tokens"), u.get("output_tokens"),
u.get("cache_read_input_tokens"),
u.get("cache_creation_input_tokens"),
data.get("num_turns"), data.get("duration_ms"),
data.get("total_cost_usd")),
)
db.commit()
return data
Using .get() matters. If a field is missing in your version, you store a NULL and the job still runs. Logging should never be the reason a job fails.
Once rows accumulate, a query per job type shows where the usage goes:
sqlite3 calls.db "SELECT job, COUNT(*), SUM(input_tokens), SUM(output_tokens), SUM(cache_read_tokens), AVG(num_turns) FROM calls GROUP BY job;"
Usage limits on a Claude Max plan: parking jobs as QUOTA_WAIT#
On a Claude Max plan, the 5-hour session limit and the weekly limit are shared with my own chat and Claude Code use. A worker that runs jobs in the background competes with whatever I am doing by hand. So at some point a call will come back saying a usage limit was reached.
I don't treat that as a failure. When the CLI reports a usage limit, my worker parks the job as QUOTA_WAIT. A separate probe resumes it later. The job keeps its place and nothing is retried in a tight loop against a limit that has not reset.
The design has three parts:
- Detect the limit. Check
is_errorand theresulttext of the JSON object, and match the usage-limit message your CLI version prints. Run a call into a limit once on purpose and save the exact output, so you match what your version really says. - Park, don't fail. Set the job's status to
QUOTA_WAITinstead ofFAILED, and keep the prompt and its inputs. - Probe later. A small scheduled task makes one cheap call. If it succeeds, it moves the
QUOTA_WAITjobs back to the queue. If it hits the limit again, it does nothing and waits for the next run.
A job status column is all the state this needs:
def handle(job_id, data):
if data.get("is_error") and looks_like_usage_limit(data.get("result", "")):
set_status(job_id, "QUOTA_WAIT")
elif data.get("is_error"):
set_status(job_id, "FAILED")
else:
set_status(job_id, "DONE")
Here looks_like_usage_limit is your own function that matches the message you captured. Because the log table records every call, you can also see how much a stretch of work used before the limit hit.
Claude code output format stream-json: when to use it instead#
stream-json prints newline-delimited JSON events while the run is in progress, instead of one object at the end. In print mode it needs --verbose:
claude -p "Refactor the parser" --output-format stream-json --verbose
Use it when you want to watch progress, such as tool calls and intermediate messages, as they happen. The last event is the result, and it carries the same summary data (turns, duration, usage).
For per-call logging, I use plain json. One object per call is simpler to parse and has fewer failure modes. A truncated stream means you must handle a partial last line, while a single JSON document either parses or doesn't. Switch to stream-json only if you need the live events.
Reading and formatting the JSON output#
The raw output is a single long line, which is hard to read by eye. Three ways to fix that:
- In the terminal:
jq . out.jsonpretty-prints it.python3 -m json.tool out.jsondoes the same with no extra install. - In VS Code: open the file and run Format Document. The default shortcut is Shift+Alt+F on Windows and Linux, and Shift+Option+F on macOS. You can also open the command palette and search for "Format Document". Set the language mode to JSON first if the file has no
.jsonextension. - To pull out one field:
jq -r '.result' out.jsongives you only the answer text, andjq '.usage' out.jsongives you the token counts.
"Format JSON" means re-indenting it with consistent line breaks. It doesn't change the data. This is separate from --output-format json, which sets what the CLI prints. People often confuse the two because the words are close.
What went wrong and what I'd do differently#
The mistake was running with plain claude -p first. It gave me no usage numbers, so I had no way to say which jobs used the most or why the limit was hit. I should have started with --output-format json from the first call, because the log is only useful for the calls you made after you started it.
Other things I would do from the start:
- Keep the raw JSON for each call, or at least the fields you log, so a parsing bug doesn't lose data.
- Treat a usage limit as a normal state of the job (
QUOTA_WAIT) from day one, not as an error to patch in later. - Don't assume field names. Check the real output on your installed version, as shown above.
Checklist#
- Run
claude -p "..." --output-format jsonfor every automated call. - Inspect the output with
jq 'keys'andjq '.usage'on your own version. - Log tokens, cache reads, turns, duration,
is_errorandsession_idper call. - Use
.get()when parsing, so a missing field becomes NULL instead of a crash. - Match the usage-limit message your CLI prints, and park those jobs as
QUOTA_WAIT. - Run a probe on a schedule to resume parked jobs.
- Use
stream-json(with--verbose) only if you need live events. - Remember that a Max plan's session and weekly limits are shared with your own chat and Claude Code use.
Quick answers
How do I get JSON output from claude -p?
Add --output-format json to the command, for example claude -p "your prompt" --output-format json. The CLI prints one JSON object with the answer in result, plus usage, turns and duration.
What is the difference between json and stream-json output formats in Claude Code?
json prints a single object when the run finishes. stream-json prints newline-delimited events as the run progresses, and in print mode it needs --verbose. I use json for per-call logging because it is simpler to parse.
How do I format a JSON file in VS Code?
Open the file and run Format Document, which is Shift+Alt+F on Windows and Linux or Shift+Option+F on macOS. Make sure the language mode is set to JSON.
What does explain format json mean?
Formatting JSON means re-indenting it so it is readable, without changing the data. It is different from --output-format json, which tells Claude Code what to print.
Next note