headroomlabs-ai/headroom · error
items_json must be a JSON array, got {}
Error message
items_json must be a JSON array, got {} What it means
Python-binding panic from crush_array_json: the string parsed as valid JSON, but the top-level value is not an array (type_name of the actual value is embedded). The Rust API is crush_array — it iterates a JSON array of items; objects, strings, numbers, or null at the top level are rejected loudly inside the GIL-released closure.
Source
Thrown at crates/headroom-py/src/lib.rs:824
fn crush_array_json<'py>(
&self,
py: Python<'py>,
items_json: &str,
query: &str,
bias: f64,
) -> Bound<'py, PyDict> {
// GIL-release pattern: own the inputs, do all heavy compute
// (JSON parse, crush, re-serialize) without the GIL, then
// re-acquire to build the PyDict from the owned outputs.
let items_json = items_json.to_string();
let query = query.to_string();
let (kept_json, ccr_hash, dropped_summary, strategy_info, compacted, compaction_kind) = py
.detach(|| {
let parsed: serde_json::Value = serde_json::from_str(&items_json)
.unwrap_or_else(|e| panic!("items_json must be JSON: {e}"));
let items = match parsed {
serde_json::Value::Array(a) => a,
other => panic!("items_json must be a JSON array, got {}", type_name(&other)),
};
let result = self.inner.crush_array(&items, &query, bias);
let kept_json = serde_json::to_string(&serde_json::Value::Array(result.items))
.expect("serialize kept items");
(
kept_json,
result.ccr_hash,
result.dropped_summary,
result.strategy_info,
result.compacted,
result.compaction_kind,
)
});
build_crush_array_dict(
py,
kept_json,
ccr_hash,
dropped_summary,View on GitHub (pinned to 322425c43b)
Solutions
- Extract the array before calling: pass envelope['data'] / the inner list, not the wrapper object.
- If the input is a whole document (mixed shapes, nested objects), call compact_document_json instead — its doc says it is for document-shape compaction rather than statistical row drop.
- Guard on the Python side: json.loads then isinstance(x, list) before the call.
- Check for double encoding — json.dumps(json.dumps(x)) produces a string-typed top level.
Example fix
# before
result = hr.crush_array_json(json.dumps({"data": items}), query, 0.5)
# after
result = hr.crush_array_json(json.dumps(items), query, 0.5) Defensive patterns
Strategy: type-guard
Validate before calling
# Validate top-level shape before calling
import json
def as_json_array(items_json: str) -> list | None:
try:
v = json.loads(items_json)
except json.JSONDecodeError:
return None
return v if isinstance(v, list) else None Type guard
def is_json_array_string(s: str) -> bool:
try:
return isinstance(json.loads(s), list)
except (json.JSONDecodeError, TypeError):
return False Try / catch
if not is_json_array_string(items_json):
raise ValueError("items_json must serialize a JSON array")
try:
result = hr.crush_array_json(items_json, query, bias)
except RuntimeError as e:
raise ValueError(f"crush_array_json failed: {e}") from e Prevention
- Unwrap envelope objects ({'data': [...]}) before serializing.
- Use compact_document_json for document-shaped payloads, crush_array_json for row arrays.
- Watch for double json.dumps producing a string top-level.
When it happens
Trigger: Passing a JSON object like '{"items": [...]}' instead of the bare array '[...]'; passing a JSON string '"[1,2]"' (double-encoded); passing 'null' or a bare number; a document-shaped payload where only the nested array should be crushed.
Common situations: Wrapping responses in an envelope ({data: [...]}) and passing the whole envelope; double-serialization bugs (json.dumps applied twice); intending compact_document_json (the lossless walker for document shapes) but calling crush_array_json.
Related errors
- items_json must be JSON: {e}
- doc_json must be JSON: {e}
- {wheel.name} does not contain headroom/_core native extensio
- invalid pipeline config TOML: {0}
- recommendations file not found: {0}
AI-assisted analysis of headroomlabs-ai/headroom@322425c43b (2026-08-15).
Data as JSON: /api/errors/6c9fabc287aebbb3.
Report an issue: GitHub.