Discussion about why JSON is useful when querying databases and why it is preferable over an ORM.
(If you don’t care about history and are familiar with ORMs you can skip to the Querying using JSON section)
Introduction
The original design of SQL did not predict the needs of querying nested data without aggregation. The initial implementation of SQL (called SEQUEL at the time) was created by IBM in the early 1970s to perform operations on their database management system, System R. This was useful for generating reports but not for querying complex data structures. Once the need for building nested data structures using data from a SQL database was discovered, the immediate question was: how? There have been many attempts at solving this problem, each with pros and cons.
Object Relation Mappers (ORM)
The responsibility of an ORM is to handle mapping projections from/to the database and language-specific data structures. Many “full” ORM frameworks tend to be overkill and attempt to solve problems that don’t exist; I am specifically referring to ORMs that write complex DQL statements for the programmer. This introduces a plethora of issues such as the object-relational impedance mismatch problem, N+1 query problem, and difficulty debugging queries, just to name a few. This pales in comparison to the worst issue caused by these frameworks: programmers not understanding the SQL being run against the database. When a decently sized team uses an ORM, issues will arise where inefficient SQL that grinds production servers to a halt is deployed. We have many reasons to avoid querying via ORM’s, so what is the solution? The solution is simple: continue writing queries but build nested structures inside our SQL.
NOTE: Many modern ORM’s provide mechanisms to avoid the N+1 problem such as eager loading, but this is not the default in many of them. For example Hibernate, a popular Java ORM, defaults to lazy loading one to many and many to many relationships. This kind of behavior is a ticking time bomb for developers that wish to treat SQL as a dumb persistent data layer with key lookups.
There are genuine use cases for ORMs, these frameworks exist for a reason. They are useful for writing DML statements in the vast majority of use cases. They can also allow an application to hook into multiple databases, when making a tool/product this may be a requirement. Lastly they can make simple DQL that deal with one (maybe two) tables less verbose.
Querying using JSON
The official SQL standard has required support for building JSON since 2016 and many popular RDBMS such as SQL Server, PostgreSQL, and MySQL support this. One benefit of building nested data structures in the database is ability to retrieve all required data in a single query. This eliminates the N+1 problem and removes the need for complex query builders that understand relationships between entities. If we take this approach the SQL is no longer hidden from the programmer, making query debugging much easier.
Examples
For these examples I am using PostgreSQL but the same principles apply to any RDBMS that supports JSON. Every example will use a simple system that contains customers, orders, and products as described by the ERD below.
Querying Nested Relationships with JSON
First, we will write a query that retrieves all orders for a customer and include all of the products on those orders. We will use two functions defined in the SQL standard for this:
JSON_ARRAYAGGandJSON_OBJECT. We want the data structured such that customer -> orders -> products, so first we will get all customers. For our example, let’s say we are worried about the first 5 customers, but we can filter however we want here.
SELECT name,
customer_id
FROM customer
LIMIT 5
Next, we want to get all orders for these filtered customers; we will put the customer
SELECTin a CTE, as well as our orderSELECT. Doing this will allow other CTEs to filter results before performing potentially costly operations such asGROUP BY. This step also uses our JSON functions; in the summary query we willGROUP BYour top-level entities’ primary key, thenJSON_ARRAYAGGand build objects out of nested entities. Also notice that we must use theANY_VALUEfunction for records that we know are unique given the primary keysGROUP BY. This is because PostgreSQL is unable to determine the functional dependency of the columns from the CTE in relation to the column we are grouping by. After following these steps, we will produce a query that looks like the following:
WITH customer_cte AS (
SELECT name,
customer_id
FROM customer
LIMIT 5
),
customer_order_cte AS NOT MATERIALIZED(
SELECT o.order_id,
o.order_number,
o.fk_customer_id
FROM "order" AS o
INNER JOIN customer_cte AS cc ON cc.customer_id = o.fk_customer_id
)
SELECT cc.customer_id,
ANY_VALUE(cc."name") AS name,
JSON_ARRAYAGG(
JSON_OBJECT(
'OrderId': coc.order_id,
'OrderNumber': coc.order_number
)
) AS orders
FROM customer_cte AS cc
LEFT JOIN customer_order_cte AS coc ON cc.customer_id = coc.fk_customer_id
GROUP BY cc.customer_id;
Finally, we want all order products and some details about each of those products. Notice the
customer_order_ctenow includes aGROUP BYon its primary key. It also aggregates the nestedorder_product_cteusing the same method as in the summary query. We will end up with a query that resembles the SQL below:
WITH customer_cte AS (
SELECT name,
customer_id
FROM customer
LIMIT 5
),
order_product_cte AS NOT MATERIALIZED (
SELECT order_product_id,
fk_order_id,
quantity,
JSON_OBJECTAGG(
JSON_OBJECT(
'ProductId': p.product_id,
'Name': p.name,
'BasePriceUsd': p.base_price_usd,
'CostToProduceUsd': p.cost_to_produce_usd
) AS product_details
)
FROM order_product AS op
LEFT JOIN product AS p ON p.product_id = op.fk_product_id
),
customer_order_cte AS NOT MATERIALIZED(
SELECT o.order_id,
o.order_number,
o.fk_customer_id,
JSON_ARRAYAGG(
JSON_OBJECT(
'OrderProductId': opc.order_product_id,
'Quantity': opc.quantity,
'Details': opc.product_details
)
) products
FROM "order" AS o
LEFT JOIN order_product_cte AS opc ON opc.fk_order_id = o.order_id
INNER JOIN customer_cte AS cc ON cc.customer_id = o.fk_customer_id
GROUP BY o.order_id
)
SELECT cc.customer_id,
ANY_VALUE(cc."name") AS name,
JSON_ARRAYAGG(
JSON_OBJECT(
'OrderId': coc.order_id,
'OrderNumber': coc.order_number,
'Products': coc.products
)
) AS orders
FROM customer_cte AS cc
LEFT JOIN customer_order_cte AS coc ON cc.customer_id = coc.fk_customer_id
GROUP BY cc.customer_id;
I chose a heavily nested data structure with many duplicated data points. This is a weak point of querying nested data structures; I will talk about ways this can be optimized in the next section. For most real-world use cases, you can stop here.
ORM and JSON SQL Comparison
There are several developers that I have encountered who believe that ORM’s write/execute queries more efficiently than programmers can. This section not only disproves this claim but also provides an example of how easy using custom queries can be.
I used a SQL script to populate test data into the tables described by the ERD. It is not particularly fast, but it gets the job done. All of the tests will run with 10,000 customers, 100,000 orders, 650,438 order products, and 15,000 products. I will also be running each test using
-benchtime=3s, which guarantees each test will last a minimum of 3 seconds.
Now that we have test data and a SQL query that fulfills our initial requirements, we will compare it to an ORM. I will be using Golang with GORM. I chose Golang because of its popularity and heavy focus on web programming. Most of the concepts discussed here will broadly apply to any ORM and programming language. First, we will write a simple test using a “full” ORM select; by this I mean populating every field in the entity with values from the DB. This is how I most commonly see ORM’s used, so I believe it is important to test. We will then compare this to our SQL query using a project derived from the query results. The code for all the following testing can be found in the repository here. The tests we are about to run boil down to these snippets (the SQL is embedded from an external file).
func TestOrmFullGorm(db *gorm.DB, customer_num int) []models.Customer {
var customers []models.Customer
result := db.
Preload("Orders.OrderProducts.Product").
Limit(customer_num).
Find(&customers)
if result.Error != nil {
log.Printf("TestOrm error: %v", result.Error)
return customers
}
return customers
}
func TestJsonGorm(db *gorm.DB, customer_num int) []models.CustomerQueryModel {
var customers []models.CustomerQueryModel
err := db.Raw(queries.CustomerSqlJsonQuery, customer_num).Scan(&customers).Error
if err != nil {
log.Printf("TestJsonGorm error: %v", err)
return customers
}
return customers
}
Before I started testing, I wrote various functions to collect metrics from the database and application. This will have a small impact on performance however, each test will be affected equally. I should also note I am on Linux with a
13th Gen Intel(R) Core(TM) i5-13600KCPU using go version 1.27.1. For this first test, we will query 25 and 50 customers. Here are the results:
| Benchmark | Iterations | μs/op | AvgQueryMs | CacheHit% | MaxCPU% | MaxMemMB | MaxQueryMs | query/op | row/op | MaxCPU% | MaxMemoryMB | enKB/op | inMB/op | tnMB/op | KB/op | allocs/op |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ORMFull | 2,524 | 1,212.50 | 0.93 | 100 | 312.50 | 270.90 | 5.04 | 4 | 2,591 | 1,179 | 35.38 | 25.92 | 1.78 | 1.80 | 5,443.70 | 108,592 |
| ORMFull | 1,593 | 2,017.16 | 1.74 | 100 | 323.30 | 276 | 9.31 | 4 | 4,879 | 1,254 | 68.51 | 40.76 | 3.09 | 3.13 | 9,796.38 | 197,963 |
| JSON | 5,053 | 606.40 | 6.93 | 100 | 1,152 | 275.10 | 14.22 | 1 | 25 | 536.50 | 13.67 | 0.12 | 0.40 | 0.40 | 924.11 | 2,944 |
| JSON | 2,814 | 1,148.66 | 13.59 | 100 | 1,158 | 317.90 | 24.12 | 1 | 50 | 600.80 | 26.43 | 0.13 | 0.85 | 0.85 | 1,893.01 | 6,185 |
The JSON SQL query used less application memory/CPU, executed faster, and transferred less data over the wire compared to the ORM query. The custom JSON query did use more memory/CPU on the database side, though, which is expected since the database is aggregating our JSON data. This test is not apples-to-apples, though, for the JSON SQL variant we had to spend time writing the query and, more importantly, we are selecting less data. Let’s customize our GORM query to select only what it needs, similar to what our JSON SQL query is doing currently.
func selectivePreload(db *gorm.DB) *gorm.DB {
return db.
Select("customer_id, name").
Preload("Orders", selectOrders).
Preload("Orders.OrderProducts", selectOrderProducts).
Preload("Orders.OrderProducts.Product", selectProduct)
}
var selectOrders = func(db *gorm.DB) *gorm.DB {
return db.Select("order_id, fk_customer_id, order_number")
}
var selectOrderProducts = func(db *gorm.DB) *gorm.DB {
return db.Select("order_product_id, fk_order_id, fk_product_id, quantity")
}
var selectProduct = func(db *gorm.DB) *gorm.DB {
return db.Select("product_id, name, base_price_usd, cost_to_produce_usd")
}
func TestOrmSelectiveGorm(db *gorm.DB, customer_num int) []models.Customer {
var customers []models.Customer
result := selectivePreload(db).Limit(customer_num).Find(&customers)
if result.Error != nil {
log.Printf("TestOrmSelectiveWithoutSizeHint error: %v", result.Error)
}
return customers
}
After creating the above set of functions I ran the same test to get 25 and 50 customer and got the following result:
| Benchmark | Iterations | μs/op | AvgQueryMs | CacheHit% | MaxCPU% | MaxMemMB | MaxQueryMs | query/op | row/op | MaxCPU% | MaxMemoryMB | enKB/op | inMB/op | tnMB/op | KB/op | allocs/op |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ORM Selective | 4,006 | 901.26 | 0.56 | 100 | 273.80 | 265 | 3.86 | 4 | 2,591 | 1,253 | 31.10 | 25.88 | 0.33 | 0.35 | 3,722.28 | 83,156 |
| ORM Selective | 2,115 | 1,484.74 | 1.06 | 100 | 296.90 | 272.50 | 5.31 | 4 | 4,879 | 1,332 | 58.07 | 40.70 | 0.62 | 0.66 | 6,853.27 | 152,986 |
| JSON | 5,919 | 603.29 | 6.92 | 100 | 1,164 | 273 | 12.90 | 1 | 25 | 540.80 | 16.58 | 0.12 | 0.40 | 0.40 | 922.44 | 2,943 |
| JSON | 2,785 | 1,149.80 | 13.60 | 100 | 1,155 | 317.60 | 23.44 | 1 | 50 | 598.30 | 21.36 | 0.13 | 0.85 | 0.85 | 1,895.84 | 6,186 |
The selective ORM method uses less memory than the full select and executes in less time. More importantly, the selective method uses less total network data than our custom JSON query. This means if our database and API live on different machines, our JSON SQL query may perform worse due to latency. Our custom query is pulling through more data for two reasons: nested products are duplicated across orders, and JSON keys are verbose. If 10 of our customers have the same product on half of their orders and each customer has 100 orders, we have just duplicated 1000 product JSON objects. There are solutions to both of these problems, but they come with trade-offs and should only be implemented if performance is needed.
NOTE: Only consider the next two optimizations after streaming or paging the results has been considered or is already being done. These two strategies alone can significantly reduce an application’s memory usage, increasing throughput. I do not discuss these optimizations further since they can be applied to our ORM and JSON queries for similar performance gains.
Making our JSON GO Fast
Currently, we are dealing with a large and verbose JSON object being sent over the wire; what if we made the keys only a couple of characters? This will make everything from JSON parsing to network transfers faster. I went with a simple strategy of concatenating the first letter of each word for a given property. Our custom SQL query will now look like the following:
WITH customer_cte AS (
SELECT name,
customer_id
FROM customer
LIMIT $1
),
order_product_cte AS NOT MATERIALIZED (
SELECT order_product_id,
fk_order_id,
quantity,
JSON_OBJECT(
'pi': p.product_id,
'n': p.name,
'bpu': p.base_price_usd,
'ctp': p.cost_to_produce_usd
) AS product_details
FROM order_product AS op
LEFT JOIN product AS p ON p.product_id = op.fk_product_id
),
customer_order_cte AS NOT MATERIALIZED(
SELECT o.order_id,
o.order_number,
o.fk_customer_id,
JSON_ARRAYAGG(
JSON_OBJECT(
'opi': opc.order_product_id,
'q': opc.quantity,
'd': opc.product_details
)
) products
FROM "order" AS o
LEFT JOIN order_product_cte AS opc ON opc.fk_order_id = o.order_id
INNER JOIN customer_cte AS cc ON cc.customer_id = o.fk_customer_id
GROUP BY o.order_id
)
SELECT cc.customer_id,
ANY_VALUE(cc."name") AS name,
JSON_ARRAYAGG(
JSON_OBJECT(
'oi': coc.order_id,
'on': coc.order_number,
'p': coc.products
)
) AS orders
FROM customer_cte AS cc
LEFT JOIN customer_order_cte AS coc ON cc.customer_id = coc.fk_customer_id
GROUP BY cc.customer_id;
The original query used 38 bytes for the product detail keys and 29 bytes for order product keys. Our new query uses 10 and 5 bytes, respectively, for these same objects. That means we are saving 57 bytes for every product. The keys for the previous order object used 26 bytes, and our updated query uses 5 bytes, saving 21 bytes per order. When querying 25, customers we are returning 236 orders and 1568 products; this means we will be saving 89.3 KB in product keys and 4.9KB in order keys for a total of 94.3KB in savings. We will also need to annotate our go struct with the correct names. This comes with the obvious downsides of producing a difficult-to-read result and needing special data structures or annotations for deserialization.
The second method also requires us to update our SQL a bit; instead of returning one JSON blob, we will return two. First, we will remove the nested product details form the original query and then replace it with a unique map of all the products on our orders. This means the API layer will be responsible for adding each product to our customer order products. The updated SQL will look like the following:
WITH customer_cte AS MATERIALIZED (
SELECT
name
, customer_id
FROM customer
ORDER BY customer_id
LIMIT $1
)
, products_cte AS (
SELECT
JSON_OBJECTAGG(
product_id: JSON_OBJECT(
'Name': name
, 'BasePriceUsd': base_price_usd
, 'CostToProduceUsd': cost_to_produce_usd
)
) AS products
FROM product AS p
WHERE EXISTS (
SELECT 1
FROM order_product AS op
INNER JOIN "order" AS o ON op.fk_order_id = o.order_id
INNER JOIN customer_cte AS cc ON o.fk_customer_id = cc.customer_id
WHERE op.fk_product_id = p.product_id
)
)
, order_product_cte AS NOT MATERIALIZED (
SELECT
order_product_id
, fk_order_id
, quantity
, fk_product_id
FROM order_product
)
, customer_order_cte AS NOT MATERIALIZED (
SELECT
o.order_id
, o.order_number
, o.fk_customer_id
, JSON_ARRAYAGG(
JSON_OBJECT(
'OrderProductId': opc.order_product_id
, 'ProductId': opc.fk_product_id
, 'Quantity': opc.quantity
)
) AS products
FROM "order" AS o
LEFT JOIN order_product_cte AS opc ON o.order_id = opc.fk_order_id
INNER JOIN customer_cte AS cc ON o.fk_customer_id = cc.customer_id
GROUP BY o.order_id
)
, customers_cte AS NOT MATERIALIZED (
SELECT
cc.customer_id
, ANY_VALUE(cc.name) AS name
, JSON_ARRAYAGG(
JSON_OBJECT(
'OrderId': coc.order_id
, 'OrderNumber': coc.order_number
, 'Products': coc.products
)
) AS orders
FROM customer_cte AS cc
LEFT JOIN customer_order_cte AS coc ON cc.customer_id = coc.fk_customer_id
GROUP BY cc.customer_id
)
SELECT
(
SELECT
JSON_ARRAYAGG(JSON_OBJECT(
'CustomerId': customer_id
, 'Name': name
, 'Orders': orders
))
FROM customers_cte
) AS customers
, (SELECT products FROM products_cte) AS products;
This will significantly reduce the amount of data sent over the network since we are deduplicating entire JSON objects. I want to note that
JSON_OBJECTAGGis ANSI SQL-compliant, since I am using it for the unique product collection aggregate. This method of reducing network data transferred has downsides of needing to write extra logic in the data retrieval function to reconstruct the final result and making the query more complex. The code will now look like the following:
func TestJsonDeDupProductsGorm(db *gorm.DB, customer_num int) models.CustomerDeDupeQueryModelList {
var queryResult models.CustomerAndProductQueryModelJson
err := db.Raw(queries.CustomerSqlJsonDeDupeQuery, customer_num).First(&queryResult).Error
if err != nil {
log.Printf("TestJsonDeDupProductsGorm error: %v", err)
}
for i := range queryResult.Customers {
for j := range queryResult.Customers[i].Orders {
products := queryResult.Customers[i].Orders[j].Products
for k := range products {
products[k].Details = queryResult.Products[products[k].ProductId]
products[k].Details.ProductId = products[k].ProductId
}
}
}
return queryResult.Customers
}
Along with the above optimizations, I also implemented and collected metrics using easyjson, a library that can unmarshal JSON data from our custom queries. The easyjson library does not use reflection; rather, it generates code to marshall/unmarshall JSON before compiling. The Golang ecosystem makes this easy, though, only requiring a single
go generate ./...command and some file annotations before compiling to get everything working. I will spare you the details of how this was implemented; it was not difficult, but it is not important for this article.
I have also included metrics for increasing the number of concurrent requests being made. This is important for applications that handle a large number of users or experience high usage spikes.
| Benchmark | Iterations | ms/op | AvgQueryMs | CacheHit% | MaxCPU% | MaxMemMB | MaxQueryMs | query/op | row/op | MaxCPU% | MaxMemoryMB | enKB/op | inMB/op | tnMB/op | MB/op | allocs/op |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ORM Selective | 1,270 | 2.45 | 1.90 | 100 | 316.50 | 274.60 | 9.20 | 4 | 8,927 | 1,376 | 78.30 | 56.71 | 1.15 | 1.20 | 12.28 | 271,207 |
| ORM Selective | 1,333 | 2.27 | 2.11 | 100 | 349.30 | 304.70 | 12.90 | 4 | 8,927 | 1,505 | 266.40 | 56.79 | 1.15 | 1.20 | 12.28 | 271,194 |
| ORM Selective | 1,344 | 2.26 | 2.22 | 100 | 351.10 | 305.70 | 12.59 | 4 | 8,927 | 1,543 | 923 | 56.78 | 1.15 | 1.20 | 12.28 | 271,181 |
| ORM Selective | 313 | 9.79 | 7.52 | 100 | 289.10 | 322.50 | 41.58 | 4 | 39,291 | 1,652 | 384.90 | 156.01 | 5.12 | 5.28 | 55.17 | 1,137,998 |
| ORM Selective | 316 | 9.65 | 7.92 | 100 | 308.70 | 377 | 49.05 | 4 | 39,291 | 1,622 | 1,122 | 157.00 | 5.12 | 5.28 | 55.17 | 1,137,988 |
| ORM Selective | 324 | 9.72 | 8.06 | 100 | 313.80 | 376.50 | 45.43 | 4 | 39,291 | 1,692 | 2,218 | 156.91 | 5.12 | 5.28 | 55.19 | 1,137,985 |
| ORM Selective | 186 | 19.67 | 13.34 | 100 | 275.10 | 402 | 81.81 | 4 | 76,974 | 1,856 | 860.20 | 282.16 | 10.06 | 10.34 | 108.34 | 2,210,493 |
| ORM Selective | 166 | 18.82 | 14.89 | 100 | 290.50 | 422.30 | 134.30 | 4 | 76,974 | 2,163 | 2,201 | 286.60 | 10.06 | 10.35 | 108.42 | 2,210,509 |
| ORM Selective | 164 | 19.06 | 15.56 | 100 | 312.30 | 433 | 110 | 4 | 76,974 | 1,820 | 3,057 | 286.70 | 10.06 | 10.35 | 108.36 | 2,210,498 |
| ORM Selective | 30 | 106.05 | 169.30 | 92.84 | 388 | 669.30 | 777.20 | 4 | 381,354 | 2,495 | 5,031 | 1,491.57 | 50.05 | 51.55 | 533.33 | 10,859,595 |
| ORM Selective | 33 | 100.58 | 250.90 | 92.76 | 413.60 | 812.20 | 1,197 | 4 | 381,354 | 3,462 | 6,290 | 1,558.74 | 50.10 | 51.65 | 533.58 | 10,859,638 |
| ORM Selective | 33 | 100.90 | 249.30 | 88.41 | 464.20 | 860.10 | 1,145 | 4 | 381,354 | 3,499 | 6,622 | 1,558.50 | 50.09 | 51.65 | 533.61 | 10,859,653 |
| JSON Compact | 1,592 | 2.15 | 25.83 | 100 | 1,168 | 329.30 | 41.29 | 1 | 100 | 663.10 | 48.09 | 0.14 | 1.40 | 1.40 | 3.44 | 12,776 |
| JSON Compact | 1,574 | 2.09 | 28.64 | 100 | 1,221 | 363.90 | 49.68 | 1 | 100 | 667.70 | 44.81 | 0.15 | 1.40 | 1.40 | 3.42 | 12,776 |
| JSON Compact | 1,549 | 2.09 | 28.81 | 100 | 1,224 | 366.30 | 51.58 | 1 | 100 | 656.40 | 48.36 | 0.15 | 1.40 | 1.40 | 3.40 | 12,772 |
| JSON Compact | 339 | 10.39 | 135.50 | 100 | 1,224 | 578.30 | 204.20 | 1 | 500 | 1,053 | 159.20 | 0.23 | 7.08 | 7.08 | 16.95 | 64,410 |
| JSON Compact | 348 | 10.21 | 163.60 | 100 | 1,310 | 678.80 | 254.40 | 1 | 500 | 1,274 | 209.60 | 0.27 | 7.08 | 7.08 | 16.89 | 64,406 |
| JSON Compact | 351 | 10.25 | 164.40 | 100 | 1,311 | 677.70 | 259.30 | 1 | 500 | 1,254 | 210 | 0.27 | 7.08 | 7.08 | 16.89 | 64,408 |
| JSON Compact | 156 | 20.33 | 305.70 | 99.96 | 1,267 | 646.20 | 420.10 | 1 | 1,000 | 1,436 | 310.70 | 0.37 | 14.14 | 14.14 | 33.61 | 128,607 |
| JSON Compact | 164 | 20.58 | 404.10 | 99.93 | 1,398 | 852 | 581 | 1 | 1,000 | 1,417 | 472.30 | 0.46 | 14.14 | 14.14 | 33.58 | 128,616 |
| JSON Compact | 163 | 20.34 | 396.80 | 99.93 | 1,395 | 819.50 | 612.70 | 1 | 1,000 | 1,370 | 412.80 | 0.46 | 14.14 | 14.14 | 33.57 | 128,620 |
| JSON Compact | 26 | 123.53 | 1,772 | 65.61 | 1,152 | 3,139 | 2,065 | 1 | 5,000 | 1,579 | 1,856 | 1.67 | 71.14 | 71.14 | 168.75 | 645,902 |
| JSON Compact | 33 | 119.90 | 2,511 | 62.26 | 1,924 | 4,178 | 2,998 | 1 | 5,000 | 1,581 | 2,370 | 1.81 | 71.14 | 71.14 | 168.66 | 645,905 |
| JSON Compact | 32 | 121.59 | 2,553 | 58.86 | 1,917 | 3,896 | 3,014 | 1 | 5,000 | 1,698 | 2,168 | 1.86 | 71.14 | 71.15 | 168.66 | 645,942 |
| JSON DeDupe | 1,724 | 1.88 | 26.95 | 100 | 1,416 | 321.90 | 43.10 | 1 | 1 | 435.70 | 145 | 0.14 | 1.20 | 1.20 | 4.08 | 6,764 |
| JSON DeDupe | 1,804 | 1.83 | 35.71 | 100 | 1,483 | 374.30 | 77.84 | 1 | 1 | 450.90 | 146.90 | 0.15 | 1.20 | 1.20 | 3.97 | 6,767 |
| JSON DeDupe | 1,790 | 1.83 | 35.76 | 100 | 1,486 | 376 | 67.41 | 1 | 1 | 453.20 | 173.90 | 0.15 | 1.20 | 1.20 | 3.95 | 6,768 |
| JSON DeDupe | 350 | 8.59 | 134.20 | 100 | 1,506 | 726.90 | 200.50 | 1 | 1 | 585.40 | 358 | 0.26 | 5.15 | 5.15 | 17.41 | 28,025 |
| JSON DeDupe | 422 | 8.50 | 185.50 | 100 | 1,590 | 763.10 | 330 | 1 | 1 | 596.80 | 458.50 | 0.28 | 5.15 | 5.15 | 17.61 | 28,030 |
| JSON DeDupe | 422 | 8.48 | 182.80 | 100 | 1,585 | 871.10 | 317.70 | 1 | 1 | 730.70 | 658.70 | 0.28 | 5.15 | 5.15 | 17.28 | 28,033 |
| JSON DeDupe | 171 | 17.74 | 274.70 | 98.97 | 1,558 | 964.10 | 393 | 1 | 1 | 1,222 | 1,287 | 0.42 | 10.06 | 10.06 | 35.44 | 54,525 |
| JSON DeDupe | 208 | 17.28 | 375.10 | 99.08 | 1,566 | 752.60 | 530.10 | 1 | 1 | 1,333 | 1,414 | 0.46 | 10.06 | 10.06 | 34.01 | 54,531 |
| JSON DeDupe | 175 | 17.37 | 376.80 | 99.07 | 1,560 | 1,479 | 519.30 | 1 | 1 | 1,475 | 1,407 | 0.53 | 10.06 | 10.06 | 34.31 | 54,544 |
| JSON DeDupe | 38 | 88.10 | 1,315 | 69.68 | 1,777 | 4,836 | 1,563 | 1 | 1 | 1,661 | 3,831 | 1.49 | 49.70 | 49.70 | 188.10 | 267,419 |
| JSON DeDupe | 32 | 102.49 | 1,855 | 59.21 | 1,867 | 7,101 | 2,331 | 1 | 1 | 1,484 | 4,340 | 2.38 | 49.70 | 49.70 | 188.11 | 267,509 |
| JSON DeDupe | 32 | 99.17 | 1,767 | 66.29 | 1,934 | 6,138 | 2,290 | 1 | 1 | 1,451 | 3,888 | 2.38 | 49.70 | 49.70 | 188.12 | 267,541 |
| JSON DeDupe Compact | 1,843 | 1.75 | 24.72 | 100 | 1,380 | 323.60 | 44.14 | 1 | 1 | 451.60 | 71.98 | 0.14 | 0.98 | 0.98 | 3.55 | 6,764 |
| JSON DeDupe Compact | 1,840 | 1.71 | 32.02 | 100 | 1,454 | 354.30 | 64.84 | 1 | 1 | 461.90 | 121.20 | 0.15 | 0.98 | 0.98 | 3.42 | 6,768 |
| JSON DeDupe Compact | 1,880 | 1.71 | 32.20 | 100 | 1,468 | 350.70 | 64.67 | 1 | 1 | 480.70 | 116.40 | 0.15 | 0.98 | 0.98 | 3.44 | 6,768 |
| JSON DeDupe Compact | 370 | 8.22 | 129.40 | 100 | 1,520 | 676 | 185.90 | 1 | 1 | 537.10 | 312.40 | 0.25 | 4.20 | 4.20 | 16.51 | 28,024 |
| JSON DeDupe Compact | 375 | 8.18 | 177.80 | 100 | 1,597 | 671.20 | 308.10 | 1 | 1 | 595.60 | 566.70 | 0.30 | 4.20 | 4.20 | 16.22 | 28,032 |
| JSON DeDupe Compact | 440 | 8.15 | 177.70 | 100 | 1,594 | 675.60 | 292.20 | 1 | 1 | 679.50 | 444.70 | 0.27 | 4.20 | 4.20 | 16.33 | 28,032 |
| JSON DeDupe Compact | 192 | 16.63 | 254.30 | 99.13 | 1,505 | 761.30 | 332.60 | 1 | 1 | 1,190 | 667.90 | 0.37 | 8.18 | 8.19 | 30.16 | 54,520 |
| JSON DeDupe Compact | 198 | 16.39 | 349.50 | 98.95 | 1,544 | 1,576 | 496.20 | 1 | 1 | 1,270 | 1,116 | 0.46 | 8.18 | 8.19 | 29.86 | 54,532 |
| JSON DeDupe Compact | 195 | 16.04 | 352.50 | 98.97 | 1,562 | 965.40 | 526.50 | 1 | 1 | 770 | 751.70 | 0.47 | 8.18 | 8.19 | 30.09 | 54,540 |
| JSON DeDupe Compact | 40 | 82.90 | 1,291 | 74.72 | 1,655 | 3,905 | 1,417 | 1 | 1 | 1,965 | 3,108 | 1.37 | 40.41 | 40.41 | 169.53 | 267,395 |
| JSON DeDupe Compact | 34 | 93.25 | 1,639 | 63.27 | 1,923 | 5,261 | 2,138 | 1 | 1 | 1,500 | 4,431 | 2.17 | 40.41 | 40.41 | 169.54 | 267,497 |
| JSON DeDupe Compact | 33 | 91.46 | 1,682 | 65.35 | 1,883 | 4,051 | 2,106 | 1 | 1 | 1,319 | 3,221 | 2.23 | 40.41 | 40.41 | 169.54 | 267,519 |
| Easy JSON Compact | 1,712 | 1.94 | 26.15 | 100 | 1,294 | 318.40 | 41.03 | 1 | 100 | 513.90 | 54.21 | 0.14 | 1.40 | 1.40 | 3.45 | 12,969 |
| Easy JSON Compact | 1,732 | 1.90 | 29.56 | 100 | 1,364 | 363.60 | 53.36 | 1 | 100 | 487.30 | 44.67 | 0.14 | 1.40 | 1.40 | 3.43 | 12,970 |
| Easy JSON Compact | 1,710 | 1.90 | 29.98 | 100 | 1,359 | 359.50 | 49.37 | 1 | 100 | 515.50 | 49.72 | 0.14 | 1.40 | 1.40 | 3.42 | 12,968 |
| Easy JSON Compact | 332 | 9.42 | 134.90 | 100 | 1,404 | 563.90 | 209.10 | 1 | 500 | 764.70 | 167.90 | 0.24 | 7.08 | 7.08 | 17.02 | 65,399 |
| Easy JSON Compact | 386 | 9.24 | 166.80 | 100 | 1,459 | 679.20 | 248.40 | 1 | 500 | 962 | 180.30 | 0.26 | 7.08 | 7.08 | 16.98 | 65,398 |
| Easy JSON Compact | 388 | 9.22 | 167.30 | 100 | 1,461 | 705.80 | 250.30 | 1 | 500 | 949.80 | 195.10 | 0.26 | 7.08 | 7.08 | 16.98 | 65,401 |
| Easy JSON Compact | 183 | 19.06 | 301.90 | 99.97 | 1,467 | 847.70 | 423.60 | 1 | 1,000 | 1,344 | 371.40 | 0.33 | 14.14 | 14.14 | 33.75 | 130,587 |
| Easy JSON Compact | 178 | 18.42 | 393.80 | 99.96 | 1,477 | 726.60 | 593 | 1 | 1,000 | 1,140 | 310.70 | 0.43 | 14.14 | 14.14 | 33.75 | 130,602 |
| Easy JSON Compact | 183 | 18.38 | 397.60 | 99.97 | 1,442 | 1,014 | 584.70 | 1 | 1,000 | 1,218 | 381.80 | 0.42 | 14.14 | 14.14 | 33.74 | 130,608 |
| Easy JSON Compact | 31 | 103.91 | 1,589 | 60.59 | 1,201 | 2,964 | 1,935 | 1 | 5,000 | 1,443 | 2,018 | 1.42 | 71.14 | 71.14 | 169.50 | 655,843 |
| Easy JSON Compact | 36 | 109.01 | 2,308 | 58.71 | 1,735 | 3,995 | 2,834 | 1 | 5,000 | 1,164 | 1,876 | 1.67 | 71.14 | 71.14 | 169.49 | 655,877 |
| Easy JSON Compact | 36 | 107.37 | 2,223 | 59.57 | 1,869 | 4,266 | 2,717 | 1 | 5,000 | 1,427 | 2,135 | 1.67 | 71.14 | 71.14 | 169.52 | 655,910 |
| Easy JSON DeDupe | 1,814 | 1.75 | 27.25 | 100 | 1,513 | 334.90 | 43.74 | 1 | 1 | 319.20 | 99.36 | 0.14 | 1.20 | 1.20 | 4.03 | 6,765 |
| Easy JSON DeDupe | 1,872 | 1.72 | 37.28 | 100 | 1,607 | 381.10 | 71.17 | 1 | 1 | 333.50 | 167.90 | 0.15 | 1.20 | 1.20 | 3.93 | 6,769 |
| Easy JSON DeDupe | 1,873 | 1.71 | 36.97 | 100 | 1,605 | 384 | 70.62 | 1 | 1 | 340.40 | 145.70 | 0.15 | 1.20 | 1.20 | 3.91 | 6,770 |
| Easy JSON DeDupe | 385 | 8.17 | 135.20 | 100 | 1,632 | 605 | 188.20 | 1 | 1 | 519.90 | 589.80 | 0.25 | 5.15 | 5.15 | 17.24 | 28,026 |
| Easy JSON DeDupe | 380 | 8.05 | 187.50 | 100 | 1,697 | 847.80 | 332 | 1 | 1 | 398.20 | 435.50 | 0.30 | 5.15 | 5.15 | 17.25 | 28,034 |
| Easy JSON DeDupe | 388 | 8.04 | 188.60 | 100 | 1,689 | 830.80 | 317.50 | 1 | 1 | 402.70 | 421.20 | 0.30 | 5.15 | 5.15 | 17.02 | 28,036 |
| Easy JSON DeDupe | 192 | 16.97 | 277.20 | 98.99 | 1,599 | 933.30 | 379.50 | 1 | 1 | 1,038 | 1,178 | 0.38 | 10.06 | 10.06 | 34.70 | 54,523 |
| Easy JSON DeDupe | 217 | 16.29 | 380.50 | 99.12 | 1,662 | 1,732 | 574.50 | 1 | 1 | 909.70 | 1,289 | 0.45 | 10.06 | 10.06 | 35.15 | 54,531 |
| Easy JSON DeDupe | 192 | 16.48 | 379.30 | 99.13 | 1,670 | 1,194 | 509.40 | 1 | 1 | 1,165 | 1,285 | 0.49 | 10.06 | 10.06 | 33.74 | 54,540 |
| Easy JSON DeDupe | 39 | 84.05 | 1,348 | 75.29 | 1,777 | 4,868 | 1,480 | 1 | 1 | 1,865 | 4,019 | 1.45 | 49.70 | 49.70 | 188.10 | 267,399 |
| Easy JSON DeDupe | 38 | 89.49 | 1,670 | 65.26 | 1,570 | 3,945 | 2,289 | 1 | 1 | 1,192 | 5,236 | 2.02 | 49.70 | 49.70 | 188.11 | 267,464 |
| Easy JSON DeDupe | 40 | 87.10 | 1,679 | 64.22 | 1,938 | 6,734 | 2,229 | 1 | 1 | 1,089 | 4,447 | 1.93 | 49.70 | 49.70 | 188.11 | 267,478 |
| Easy JSON DeDupe Compact | 1,987 | 1.63 | 24.94 | 100 | 1,486 | 312.60 | 42.10 | 1 | 1 | 340.50 | 79 | 0.14 | 0.98 | 0.98 | 3.52 | 6,766 |
| Easy JSON DeDupe Compact | 1,918 | 1.60 | 33.41 | 100 | 1,575 | 368.30 | 68.06 | 1 | 1 | 353.50 | 101.80 | 0.15 | 0.98 | 0.98 | 3.45 | 6,769 |
| Easy JSON DeDupe Compact | 1,912 | 1.60 | 33.10 | 100 | 1,588 | 364.20 | 62.01 | 1 | 1 | 357.20 | 121.30 | 0.15 | 0.98 | 0.98 | 3.46 | 6,770 |
| Easy JSON DeDupe Compact | 384 | 7.84 | 130.10 | 100 | 1,626 | 575.80 | 187 | 1 | 1 | 451.60 | 388.80 | 0.24 | 4.20 | 4.20 | 16.07 | 28,025 |
| Easy JSON DeDupe Compact | 408 | 7.72 | 182.90 | 100 | 1,699 | 780.60 | 296 | 1 | 1 | 395.60 | 417.80 | 0.28 | 4.20 | 4.20 | 16.07 | 28,032 |
| Easy JSON DeDupe Compact | 393 | 7.71 | 182.60 | 100 | 1,694 | 717.20 | 308.20 | 1 | 1 | 344.40 | 411.30 | 0.29 | 4.20 | 4.20 | 16.24 | 28,036 |
| Easy JSON DeDupe Compact | 192 | 15.92 | 258.20 | 98.97 | 1,566 | 587.30 | 378.10 | 1 | 1 | 785 | 720.20 | 0.37 | 8.18 | 8.19 | 30.07 | 54,522 |
| Easy JSON DeDupe Compact | 196 | 15.53 | 357.90 | 99.13 | 1,689 | 1,444 | 514.70 | 1 | 1 | 730.70 | 951.20 | 0.47 | 8.18 | 8.19 | 30.11 | 54,537 |
| Easy JSON DeDupe Compact | 234 | 15.25 | 347.30 | 99.01 | 1,660 | 1,087 | 497.80 | 1 | 1 | 620.80 | 729.40 | 0.41 | 8.18 | 8.19 | 30.34 | 54,534 |
| Easy JSON DeDupe Compact | 42 | 89.70 | 1,295 | 64.06 | 1,751 | 3,390 | 1,528 | 1 | 1 | 1,410 | 3,742 | 1.31 | 40.41 | 40.41 | 169.53 | 267,398 |
| Easy JSON DeDupe Compact | 43 | 80.64 | 1,612 | 60.94 | 1,894 | 4,178 | 2,176 | 1 | 1 | 1,054 | 4,262 | 1.74 | 40.41 | 40.41 | 169.53 | 267,441 |
| Easy JSON DeDupe Compact | 43 | 79.60 | 1,633 | 59.48 | 1,862 | 6,034 | 2,273 | 1 | 1 | 1,008 | 2,724 | 1.74 | 40.41 | 40.41 | 169.53 | 267,470 |
These results show that the optimizations to our original query win over the ORM query in almost all cases. There are a few cases past 1000 customers where the ORM beats our “JSON Compact” & “Easy JSON Compact” optimizations. This is because using small keys alone does not tackle the root problem, which is duplicating product data across customer aggregations. When dealing with similar situations to this query, I would recommend trying to deduplicate data and creating the final object in the data access function first. If this is not enough, then shrinking the keys or using another library for deserialization are good second options as well.
Conclusion
I have talked a lot about performance in this article, mainly to convey that writing SQL by hand can be performant. That said, I still believe the strongest benefits are readability, debuggability, and transferability compared to a traditional ORM. ORMs get abandoned, programming languages can fade away, but SQL standards endure. This means the SQL you write today is relevant decades later and can persist through shifts in technology stacks.
Notes
The example presented in this article is simple, but real-world problems are rarely like this. For example, what if this query required an estimated price using
base_price_usdon each customer order? For our custom query, it would be a singularSUMaggregate, while the GORM query would need to drop into raw SQL for part of the query or do the work in the API layer. For this particular problem, imagine if we no longer required information about each product on an order, instead stopping at the order? If we still want this estimated price aggregate, we are forced to pull in a lot more data than needed or write raw SQL anyways. Debugging a hand-written SQL query is easy; you open the.sqlfile (or copy the query if you are still hardcoding strings), then execute it with some parameters. In the ORM case, it is not so simple; you are forced to implement logging so you can copy the query if you are lucky. In many cases, the ORM is executing multiple queries; in this situation, you must capture each query and analyze them.
I am aware that some programming languages, such as C# with EF, can convey nested aggregates but, EF comes with its own problems. If we wanted to replicate the queries being executed in the above tests with an estimated cost aggregate, we would need something like this:
var customers = await dbContext.Customers
.Take(customerNum)
.Select(c => new
{
CustomerId = c.CustomerId,
Name = c.Name,
Orders = c.Orders.Select(o => new
{
OrderId = o.OrderId,
FkCustomerId = o.FkCustomerId,
OrderNumber = o.OrderNumber,
EstimatedCost = o.OrderProducts.Sum(op => op.Quantity * op.Product.BasePriceUsd),
OrderProducts = o.OrderProducts.Select(op => new
{
OrderProductId = op.OrderProductId,
FkOrderId = op.FkOrderId,
FkProductId = op.FkProductId,
Quantity = op.Quantity,
Product = new
{
ProductId = op.Product.ProductId,
Name = op.Product.Name,
BasePriceUsd = op.Product.BasePriceUsd,
CostToProduceUsd = op.Product.CostToProduceUsd
}
}).ToList()
}).ToList()
})
.ToListAsync();
This query does the same thing as our GORM query (plus the estimated price aggregate) but is a lot more verbose. This is a far-cry from the simplicity promised by using an ORM.
References
- https://en.wikipedia.org/wiki/SQL
- https://en.wikipedia.org/wiki/Relational_algebra
- https://en.wikipedia.org/wiki/Tuple_relational_calculus
- https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping
- https://en.wikipedia.org/wiki/Object%E2%80%93relational_impedance_mismatch
- https://www.explainthis.io/en/swe/n-plus-one-problem
- https://learn.microsoft.com/en-us/sql/relational-databases/json/json-data-sql-server?view=sql-server-ver17
- https://en.wikipedia.org/wiki/MySQL
- https://en.wikipedia.org/wiki/PostgreSQL
- https://modern-sql.com/blog/2017-06/whats-new-in-sql-2016
- https://github.com/go-gorm/gorm
- https://github.com/mailru/easyjson
- https://github.com/bytedance/sonic
- https://github.com/goccy/go-json
- https://en.wikipedia.org/wiki/Data_query_language
- https://www.evanjones.ca/network-latencies-2021.html
- https://www.postgresql.org/docs/current/sql-altertable.html