JSON in SQL was Inevitable (Querying using JSON)

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.

customercustomer_id : uuid PKName : varchar(64) UKorderorder_id : uuid PKfk_customer_id : uuid FKorder_number : varchar(16) UKorder_productorder_product_id : uuid PKfk_order_id : uuid FKfk_product_id : uuid FKquantity : decimal(12,2)productproduct_id : uuid PKname : varchar(64) UKshort_description : varchar(128)long_description : varchar(512)base_price_usd : decimal(12,2)cost_to_produce_usd : decimal(12,2)

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_ARRAYAGG and JSON_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 SELECT in a CTE, as well as our order SELECT. Doing this will allow other CTEs to filter results before performing potentially costly operations such as GROUP BY. This step also uses our JSON functions; in the summary query we will GROUP BY our top-level entities’ primary key, then JSON_ARRAYAGG and build objects out of nested entities. Also notice that we must use the ANY_VALUE function for records that we know are unique given the primary keys GROUP 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_cte now includes a GROUP BY on its primary key. It also aggregates the nested order_product_cte using 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-13600K CPU using go version 1.27.1. For this first test, we will query 25 and 50 customers. Here are the results:

Views:
Customers:
BenchmarkIterationsμs/opAvgQueryMsCacheHit%MaxCPU%MaxMemMBMaxQueryMsquery/oprow/opMaxCPU%MaxMemoryMBenKB/opinMB/optnMB/opKB/opallocs/op
ORMFull2,5241,212.500.93100312.50270.905.0442,5911,17935.3825.921.781.805,443.70108,592
ORMFull1,5932,017.161.74100323.302769.3144,8791,25468.5140.763.093.139,796.38197,963
JSON5,053606.406.931001,152275.1014.22125536.5013.670.120.400.40924.112,944
JSON2,8141,148.6613.591001,158317.9024.12150600.8026.430.130.850.851,893.016,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:

Views:
Customers:
BenchmarkIterationsμs/opAvgQueryMsCacheHit%MaxCPU%MaxMemMBMaxQueryMsquery/oprow/opMaxCPU%MaxMemoryMBenKB/opinMB/optnMB/opKB/opallocs/op
ORM Selective4,006901.260.56100273.802653.8642,5911,25331.1025.880.330.353,722.2883,156
ORM Selective2,1151,484.741.06100296.90272.505.3144,8791,33258.0740.700.620.666,853.27152,986
JSON5,919603.296.921001,16427312.90125540.8016.580.120.400.40922.442,943
JSON2,7851,149.8013.601001,155317.6023.44150598.3021.360.130.850.851,895.846,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_OBJECTAGG is 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.

Views:
Concurrency:
Customers:
BenchmarkIterationsms/opAvgQueryMsCacheHit%MaxCPU%MaxMemMBMaxQueryMsquery/oprow/opMaxCPU%MaxMemoryMBenKB/opinMB/optnMB/opMB/opallocs/op
ORM Selective1,2702.451.90100316.50274.609.2048,9271,37678.3056.711.151.2012.28271,207
ORM Selective1,3332.272.11100349.30304.7012.9048,9271,505266.4056.791.151.2012.28271,194
ORM Selective1,3442.262.22100351.10305.7012.5948,9271,54392356.781.151.2012.28271,181
ORM Selective3139.797.52100289.10322.5041.58439,2911,652384.90156.015.125.2855.171,137,998
ORM Selective3169.657.92100308.7037749.05439,2911,6221,122157.005.125.2855.171,137,988
ORM Selective3249.728.06100313.80376.5045.43439,2911,6922,218156.915.125.2855.191,137,985
ORM Selective18619.6713.34100275.1040281.81476,9741,856860.20282.1610.0610.34108.342,210,493
ORM Selective16618.8214.89100290.50422.30134.30476,9742,1632,201286.6010.0610.35108.422,210,509
ORM Selective16419.0615.56100312.30433110476,9741,8203,057286.7010.0610.35108.362,210,498
ORM Selective30106.05169.3092.84388669.30777.204381,3542,4955,0311,491.5750.0551.55533.3310,859,595
ORM Selective33100.58250.9092.76413.60812.201,1974381,3543,4626,2901,558.7450.1051.65533.5810,859,638
ORM Selective33100.90249.3088.41464.20860.101,1454381,3543,4996,6221,558.5050.0951.65533.6110,859,653
JSON Compact1,5922.1525.831001,168329.3041.291100663.1048.090.141.401.403.4412,776
JSON Compact1,5742.0928.641001,221363.9049.681100667.7044.810.151.401.403.4212,776
JSON Compact1,5492.0928.811001,224366.3051.581100656.4048.360.151.401.403.4012,772
JSON Compact33910.39135.501001,224578.30204.2015001,053159.200.237.087.0816.9564,410
JSON Compact34810.21163.601001,310678.80254.4015001,274209.600.277.087.0816.8964,406
JSON Compact35110.25164.401001,311677.70259.3015001,2542100.277.087.0816.8964,408
JSON Compact15620.33305.7099.961,267646.20420.1011,0001,436310.700.3714.1414.1433.61128,607
JSON Compact16420.58404.1099.931,39885258111,0001,417472.300.4614.1414.1433.58128,616
JSON Compact16320.34396.8099.931,395819.50612.7011,0001,370412.800.4614.1414.1433.57128,620
JSON Compact26123.531,77265.611,1523,1392,06515,0001,5791,8561.6771.1471.14168.75645,902
JSON Compact33119.902,51162.261,9244,1782,99815,0001,5812,3701.8171.1471.14168.66645,905
JSON Compact32121.592,55358.861,9173,8963,01415,0001,6982,1681.8671.1471.15168.66645,942
JSON DeDupe1,7241.8826.951001,416321.9043.1011435.701450.141.201.204.086,764
JSON DeDupe1,8041.8335.711001,483374.3077.8411450.90146.900.151.201.203.976,767
JSON DeDupe1,7901.8335.761001,48637667.4111453.20173.900.151.201.203.956,768
JSON DeDupe3508.59134.201001,506726.90200.5011585.403580.265.155.1517.4128,025
JSON DeDupe4228.50185.501001,590763.1033011596.80458.500.285.155.1517.6128,030
JSON DeDupe4228.48182.801001,585871.10317.7011730.70658.700.285.155.1517.2828,033
JSON DeDupe17117.74274.7098.971,558964.10393111,2221,2870.4210.0610.0635.4454,525
JSON DeDupe20817.28375.1099.081,566752.60530.10111,3331,4140.4610.0610.0634.0154,531
JSON DeDupe17517.37376.8099.071,5601,479519.30111,4751,4070.5310.0610.0634.3154,544
JSON DeDupe3888.101,31569.681,7774,8361,563111,6613,8311.4949.7049.70188.10267,419
JSON DeDupe32102.491,85559.211,8677,1012,331111,4844,3402.3849.7049.70188.11267,509
JSON DeDupe3299.171,76766.291,9346,1382,290111,4513,8882.3849.7049.70188.12267,541
JSON DeDupe Compact1,8431.7524.721001,380323.6044.1411451.6071.980.140.980.983.556,764
JSON DeDupe Compact1,8401.7132.021001,454354.3064.8411461.90121.200.150.980.983.426,768
JSON DeDupe Compact1,8801.7132.201001,468350.7064.6711480.70116.400.150.980.983.446,768
JSON DeDupe Compact3708.22129.401001,520676185.9011537.10312.400.254.204.2016.5128,024
JSON DeDupe Compact3758.18177.801001,597671.20308.1011595.60566.700.304.204.2016.2228,032
JSON DeDupe Compact4408.15177.701001,594675.60292.2011679.50444.700.274.204.2016.3328,032
JSON DeDupe Compact19216.63254.3099.131,505761.30332.60111,190667.900.378.188.1930.1654,520
JSON DeDupe Compact19816.39349.5098.951,5441,576496.20111,2701,1160.468.188.1929.8654,532
JSON DeDupe Compact19516.04352.5098.971,562965.40526.5011770751.700.478.188.1930.0954,540
JSON DeDupe Compact4082.901,29174.721,6553,9051,417111,9653,1081.3740.4140.41169.53267,395
JSON DeDupe Compact3493.251,63963.271,9235,2612,138111,5004,4312.1740.4140.41169.54267,497
JSON DeDupe Compact3391.461,68265.351,8834,0512,106111,3193,2212.2340.4140.41169.54267,519
Easy JSON Compact1,7121.9426.151001,294318.4041.031100513.9054.210.141.401.403.4512,969
Easy JSON Compact1,7321.9029.561001,364363.6053.361100487.3044.670.141.401.403.4312,970
Easy JSON Compact1,7101.9029.981001,359359.5049.371100515.5049.720.141.401.403.4212,968
Easy JSON Compact3329.42134.901001,404563.90209.101500764.70167.900.247.087.0817.0265,399
Easy JSON Compact3869.24166.801001,459679.20248.401500962180.300.267.087.0816.9865,398
Easy JSON Compact3889.22167.301001,461705.80250.301500949.80195.100.267.087.0816.9865,401
Easy JSON Compact18319.06301.9099.971,467847.70423.6011,0001,344371.400.3314.1414.1433.75130,587
Easy JSON Compact17818.42393.8099.961,477726.6059311,0001,140310.700.4314.1414.1433.75130,602
Easy JSON Compact18318.38397.6099.971,4421,014584.7011,0001,218381.800.4214.1414.1433.74130,608
Easy JSON Compact31103.911,58960.591,2012,9641,93515,0001,4432,0181.4271.1471.14169.50655,843
Easy JSON Compact36109.012,30858.711,7353,9952,83415,0001,1641,8761.6771.1471.14169.49655,877
Easy JSON Compact36107.372,22359.571,8694,2662,71715,0001,4272,1351.6771.1471.14169.52655,910
Easy JSON DeDupe1,8141.7527.251001,513334.9043.7411319.2099.360.141.201.204.036,765
Easy JSON DeDupe1,8721.7237.281001,607381.1071.1711333.50167.900.151.201.203.936,769
Easy JSON DeDupe1,8731.7136.971001,60538470.6211340.40145.700.151.201.203.916,770
Easy JSON DeDupe3858.17135.201001,632605188.2011519.90589.800.255.155.1517.2428,026
Easy JSON DeDupe3808.05187.501001,697847.8033211398.20435.500.305.155.1517.2528,034
Easy JSON DeDupe3888.04188.601001,689830.80317.5011402.70421.200.305.155.1517.0228,036
Easy JSON DeDupe19216.97277.2098.991,599933.30379.50111,0381,1780.3810.0610.0634.7054,523
Easy JSON DeDupe21716.29380.5099.121,6621,732574.5011909.701,2890.4510.0610.0635.1554,531
Easy JSON DeDupe19216.48379.3099.131,6701,194509.40111,1651,2850.4910.0610.0633.7454,540
Easy JSON DeDupe3984.051,34875.291,7774,8681,480111,8654,0191.4549.7049.70188.10267,399
Easy JSON DeDupe3889.491,67065.261,5703,9452,289111,1925,2362.0249.7049.70188.11267,464
Easy JSON DeDupe4087.101,67964.221,9386,7342,229111,0894,4471.9349.7049.70188.11267,478
Easy JSON DeDupe Compact1,9871.6324.941001,486312.6042.1011340.50790.140.980.983.526,766
Easy JSON DeDupe Compact1,9181.6033.411001,575368.3068.0611353.50101.800.150.980.983.456,769
Easy JSON DeDupe Compact1,9121.6033.101001,588364.2062.0111357.20121.300.150.980.983.466,770
Easy JSON DeDupe Compact3847.84130.101001,626575.8018711451.60388.800.244.204.2016.0728,025
Easy JSON DeDupe Compact4087.72182.901001,699780.6029611395.60417.800.284.204.2016.0728,032
Easy JSON DeDupe Compact3937.71182.601001,694717.20308.2011344.40411.300.294.204.2016.2428,036
Easy JSON DeDupe Compact19215.92258.2098.971,566587.30378.1011785720.200.378.188.1930.0754,522
Easy JSON DeDupe Compact19615.53357.9099.131,6891,444514.7011730.70951.200.478.188.1930.1154,537
Easy JSON DeDupe Compact23415.25347.3099.011,6601,087497.8011620.80729.400.418.188.1930.3454,534
Easy JSON DeDupe Compact4289.701,29564.061,7513,3901,528111,4103,7421.3140.4140.41169.53267,398
Easy JSON DeDupe Compact4380.641,61260.941,8944,1782,176111,0544,2621.7440.4140.41169.53267,441
Easy JSON DeDupe Compact4379.601,63359.481,8626,0342,273111,0082,7241.7440.4140.41169.53267,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_usd on each customer order? For our custom query, it would be a singular SUM aggregate, 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 .sql file (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

Jacob Gonzales

Software Engineer