I was recently digging into Immich’s API to see if it provides the fields I needed to get some specific information about my photos. Immich is my self-hosted photo-management service. The API response returned hundreds of lines of JSON data. I just wanted to see its structure to verify the fields I needed, but scrolling through so much data felt time-consuming. So I did what any sane programmer would do. I spent even more time trying to figure out how I could do it in an automated way.
I’ve used jq before for quick JSON tweaks (it’s basically sed or awk for JSON), so I wondered if it could help here. A bit of searching showed that jq can collapse arrays to a single-item preview and then recurse through the entire document, giving you a clean outline of the JSON’s shape in one pass.
I later found out that the response structures are already specified in the Immich API documentation. So I could have just looked that up and saved myself all the hassle. But hey, it could well have been an API endpoint without any documentation and the method that I came up with is general enough to be used in any API JSON response.
The jq function
Here’s the jq function I came up with which can be run in a terminal:
jq '
def shape:
if type == "array" then
if length > 0 then [ (.[0] | shape) ] else [] end
elif type == "object" then
with_entries(.value |= shape)
else
type
end;
shape
' response.json
It checks the type of each value: if it's an array, keep just the first item and recurse. If it's an object, walk through each key and recurse into the value. If it's a primitive like a string or number, just return the type name.
Actual Immich API Shape
Here's a simplified output of the jq function applied to the Immich API response:
{
"albums": {
"count": "number",
...
},
"assets": {
"count": "number",
"facets": [],
"items": [
{
...
"duration": "string",
"exifInfo": {
"city": "string",
"country": "string",
...
},
"fileCreatedAt": "string",
"fileModifiedAt": "string",
"hasMetadata": "boolean",
...
}
],
"nextPage": "string",
"total": "number"
}
}
Now I instantly see that assets.items[i].exifInfo.country is a string—exactly what I needed to know to map photos by country.
Extending It for Sample Values
Next, I wondered: what if I wanted real examples, not just types? I modified the primitive case to return the value itself:
jq '
def shape_with_samples:
if type == "array" then
if length > 0 then [ (.[0] | shape_with_samples) ] else [] end
elif type == "object" then
with_entries(.value |= shape_with_samples)
else
.
end;
shape_with_samples
' response.json
Now I get sample data like:
{
"albums": {
"count": 10,
...
},
"assets": {
"count": 25,
"facets": [],
"items": [
{
...
"duration": "00:00:05",
"exifInfo": {
"city": "Zurich",
"country": "Switzerland"
},
"fileCreatedAt": "2025-09-01T10:00:00Z",
"fileModifiedAt": "2025-09-01T10:05:00Z",
"hasMetadata": true,
...
}
],
"nextPage": "2",
"total": 25
}
}
Seeing "Switzerland" immediately confirmed I was pulling the right field.
Sampling Multiple Items
When the first asset lacked certain optional tags, I extended the function to sample the first N items:
jq '
def shape_with_samples(n):
if type == "array" then
if length > 0 then
[ .[:n][] | shape_with_samples(n) ]
else [] end
elif type == "object" then
with_entries(.value |= shape_with_samples(n))
else
.
end;
shape_with_samples(3)
' response.json
Making it reusable
I will definitely not remember these functions in a month, so I saved them as reusable scripts:
~/.jq/shape.jq
~/.jq/shape_samples.jq
Now I can run them anytime via:
jq -f ~/.jq/shape.jq response.json
jq -f ~/.jq/shape_with_samples.jq response.json
Conclusion
It’s a tiny trick, but it makes understanding large JSON responses so much easier. Whenever you are working with any undocumented APIs, jq gives you a fast, lightweight way to map its structure without guesswork.
