Newsletter
TechAnV Blog
Get updates on security engineering, Rust, eBPF, and DevSecOps. No spam, unsubscribe anytime.
Check your inbox and click the confirmation link to complete your subscription.
Returning related rows in a single SQL query using JSON#
When building database-backed applications you’ll often find yourself wanting to return a row from the database along with its related rows.
A few examples:
- Retrieving a list of congressional legislators and their terms, following a foreign key relationship
- Return blog entries and their tags in one go, via a many-to-many table
You can do this in SQLite using the json_group_array() aggregation function. A couple of examples.
Legislators and their terms, via a foreign key#
Simplified schema for this database:
1CREATE TABLE [legislators] (2 [id] TEXT PRIMARY KEY,3 [name] TEXT,4 [bio_birthday] TEXT5);6CREATE TABLE [legislator_terms] (7 [legislator_id] TEXT REFERENCES [legislators]([id]),8 [type] TEXT,9 [state] TEXT,10 [start] TEXT,11 [end] TEXT,12 [party] TEXT13);Here’s a query that returns each legislator along with a JSON array of their terms:
1select2 legislators.id,3 legislators.name,4 json_group_array(json_object(5 'type', legislator_terms.type,6 'state', legislator_terms.state,7 'start', legislator_terms.start,8 'end', legislator_terms.end,9 'party', legislator_terms.party10 )) as terms,11 count(*) as num_terms12from13 legislators join legislator_terms on legislator_terms.legislator_id = legislators.id14 group by legislators.id15order by16 id17limit18 10And the result:

Note that this query does group by legislators.id which is allowed in SQLite but may not work in other databases, which might require group by legislators.id, legislators.name instead.
Tags on blog entries, via a many-to-many table#
Simplified schema:
1CREATE TABLE [blog_entry] (2 [id] INTEGER PRIMARY KEY,3 [title] TEXT4);5
6CREATE TABLE [blog_tag] (7 [id] INTEGER PRIMARY KEY,8 [tag] TEXT9);10
11CREATE TABLE [blog_entry_tags] (12 [id] INTEGER PRIMARY KEY,13 [entry_id] INTEGER,14 [tag_id] INTEGER,15 FOREIGN KEY([entry_id]) REFERENCES [blog_entry]([id]),16 FOREIGN KEY([tag_id]) REFERENCES [blog_tag]([id])17);Query to retrieve entries with their tags:
1select2 blog_entry.id,3 blog_entry.title,4 json_group_array(json_object('tag', blog_tag.tag)) as tags5from6 blog_entry7 join blog_entry_tags on blog_entry.id = blog_entry_tags.entry_id8 join blog_tag on blog_tag.id = blog_entry_tags.tag_id9group by10 blog_entry.id11order by12 blog_entry.id desc| id | title | tags |
|---|---|---|
| 8191 | I don’t know how to solve prompt injection | [{“tag”:“ai”},{“tag”:“security”},{“tag”:“openai”}] |
| 8190 | Weeknotes: Datasette Lite, s3-credentials, shot-scraper, datasette-edit-templates and more | [{“tag”:“shotscraper”},{“tag”:“datasette”},{“tag”:“plugins”},{“tag”:“datasettelite”},{“tag”:“projects”},{“tag”:“s3credentials”},{“tag”:“weeknotes”}] |
| 8189 | Prompt injection attacks against GPT-3 | [{“tag”:“ai”},{“tag”:“gpt3”},{“tag”:“security”},{“tag”:“openai”}] |
There’s a subtle bug in the above: if an entry has no tags at all it will be excluded from the query results entirely.
You can fix that using left joins like this:
1select2 blog_entry.id,3 blog_entry.title,4 json_group_array(json_object('tag', blog_tag.tag)) as tags5from6 blog_entry7 left join blog_entry_tags on blog_entry.id = blog_entry_tags.entry_id8 left join blog_tag on blog_tag.id = blog_entry_tags.tag_id9where blog_entry.id < 410group by11 blog_entry.id12order by13 blog_entry.id descThis almost works, but it outputs the following returning {"tag": null} for entries with no tags:
| id | title | tags |
|---|---|---|
| 3 | Todo list | [{“tag” |
| 2 | Blogging aint easy | [{“tag” |
| 1 | WaSP Phase II | [{“tag” |
David Fetter showed me the solution:
1select2 blog_entry.id,3 blog_entry.title,4 json_group_array(5 json_object('tag', blog_tag.tag)6 ) filter (7 where8 blog_tag.tag is not null9 ) as tags10from11 blog_entry12 left join blog_entry_tags on blog_entry.id = blog_entry_tags.entry_id13 left join blog_tag on blog_tag.id = blog_entry_tags.tag_id14group by15 blog_entry.id16order by17 blog_entry.idThat extra filter on the aggregation does the trick!
Other databases#
Other databases are capable of the same thing, but using different functions. PostgreSQL has json_agg() for example, which is also available in Django as JSONBAgg.
Here’s an equivalent query in PostgreSQL syntax:
1select2 blog_entry.id,3 title,4 slug,5 created,6 coalesce(json_agg(json_build_object(blog_tag.id, blog_tag.tag)) filter (7 where8 blog_tag.tag is not null9 ), json_build_array()) as tags10from11 blog_entry12 left join blog_entry_tags on blog_entry.id = blog_entry_tags.entry_id13 left join blog_tag on blog_entry_tags.tag_id = blog_tag.id14group by15 blog_entry.id16order by17 blog_entry.idSee that running here in django-sql-dashboard.