Continuing with part 2 of our article on execution plans, we will look at how to analyze and interpret an execution plan in the PostgreSQL database.

Following my usual approach to this kind of analysis, we can work with 2 modes:

  1. Execution plan in text/tabular mode, where the steps are shown in indented form.
  2. Execution plan in tree mode, where the steps are shown graphically.

Similar to Oracle Database, PostgreSQL uses the following methodology for interpreting execution plans: the execution plan is read from the bottom up and from right to left, reflecting the sequence in which PostgreSQL executes the operations.

In PostgreSQL, execution plans are read from the bottom up and from right to left. This means that:

This approach makes it easier to understand the flow of query execution and identify potential performance bottlenecks.

To make the explanation more concrete, we will analyze a sample query, interpret its execution plan, and then look at possible optimizations to make the SQL run more efficiently.

 SELECT * FROM (
    (SELECT
        poi.supplier_product ->> 'supplier_product_name' AS name,
        poi.price AS price,
        poi.created_at AS expires_at,
        poi.supplier_product ->> 'supplier_product_manufacturer' AS manufacturer,
        poi.supplier_product ->> 'supplier_product_packing' AS packing,
        poi.supplier_product ->> 'supplier_product_pack_quantity' AS pack_quantity,
        poi.supplier_product ->> 'supplier_product_unit_purchase' AS unit_purchase,
        poi.confirmed_quantity AS quantity_price,
        sp.name AS name_supply,
        sp.identifier AS identifier_supply,
        1 AS order_by
    FROM purchase_order_items poi
    INNER JOIN purchase_orders po ON po.id = poi.purchase_order_id
    INNER JOIN buyer_products bp ON bp.buyer_id = po.buyer_id AND
        bp.code = poi.buyer_product ->> 'buyer_product_code'
    INNER JOIN products p ON p.id = bp.base_product_id
    INNER JOIN suppliers sp ON sp.id = po.supplier_id
    WHERE bp.id = 12081956
        AND poi.created_at < '2025-02-21 16:57:59.225571'
    ORDER BY poi.created_at DESC
    LIMIT 3
    )
    UNION ALL
    (SELECT
        bp.name,
        bp.source_price AS price,
        bp.created_at AS expires_at,
        '' AS manufacturer,
        '' AS packing,
        '' AS pack_quantity,
        '' AS unit_purchase,
        0 AS quantity_price,
        '' AS name_supply, 
        '' AS identifier_supply,
        2 AS order_by
    FROM public.buyer_products bp
    INNER JOIN public.products p ON p.id = bp.base_product_id
    WHERE bp.id = 12081956
        AND bp.source_price IS NOT NULL
    )
) b
ORDER BY order_by
LIMIT 3;

To analyze the execution plans, we will use two tools that help visualize and interpret the data. Each tool presents the plan in a different way, allowing for a more complete analysis:

  1. Tabular mode: Explain Depesz: Displays the execution plan in tabular format, making it easier to read the steps in a structured way.
    https://explain.depesz.com/

  2. Tree mode: Explain Dalibo: Represents the execution plan graphically, making the hierarchy of operations more intuitive.
    https://explain.dalibo.com/

📌 Instructions: Click the links above to access the tools and follow the visualization procedure for both methods.

To view the execution plan in both modes, we first need to extract it in text format. To do that, simply add the following statement at the beginning of the query:

EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS)
(...) SQL CODE (...)

This statement provides detailed information about query execution, including:

Below, see what the complete SQL code will look like with EXPLAIN added to the query.

 EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS )
 SELECT * FROM (
    (SELECT
        poi.supplier_product ->> 'supplier_product_name' AS name,
        poi.price AS price,
        poi.created_at AS expires_at,
        poi.supplier_product ->> 'supplier_product_manufacturer' AS manufacturer,
        poi.supplier_product ->> 'supplier_product_packing' AS packing,
        poi.supplier_product ->> 'supplier_product_pack_quantity' AS pack_quantity,
        poi.supplier_product ->> 'supplier_product_unit_purchase' AS unit_purchase,
        poi.confirmed_quantity AS quantity_price,
        sp.name AS name_supply,
        sp.identifier AS identifier_supply,
        1 AS order_by
    FROM purchase_order_items poi
    INNER JOIN purchase_orders po ON po.id = poi.purchase_order_id
    INNER JOIN buyer_products bp ON bp.buyer_id = po.buyer_id AND
        bp.code = poi.buyer_product ->> 'buyer_product_code'
    INNER JOIN products p ON p.id = bp.base_product_id
    INNER JOIN suppliers sp ON sp.id = po.supplier_id
    WHERE bp.id = 12081956
        AND poi.created_at < '2025-02-21 16:57:59.225571'
    ORDER BY poi.created_at DESC
    LIMIT 3
    )
    UNION ALL
    (SELECT
        bp.name,
        bp.source_price AS price,
        bp.created_at AS expires_at,
        '' AS manufacturer,
        '' AS packing,
        '' AS pack_quantity,
        '' AS unit_purchase,
        0 AS quantity_price,
        '' AS name_supply, 
        '' AS identifier_supply,
        2 AS order_by
    FROM public.buyer_products bp
    INNER JOIN public.products p ON p.id = bp.base_product_id
    WHERE bp.id = 12081956
        AND bp.source_price IS NOT NULL
    )
) b
ORDER BY order_by
LIMIT 3;

After running the EXPLAIN command, PostgreSQL generates an execution plan in text format. Below is an example of the plan generated for this query:



 Limit  (cost=120200.56..120200.57 rows=2 width=252) (actual time=207.212..207.217 rows=3 loops=1)
   Output: ((poi.supplier_product ->> 'supplier_product_name'::text)), poi.price, poi.created_at, ((poi.supplier_product ->> 'supplier_product_manufacturer'::text)), ((poi.supplier_product ->> 'supplier_product_packing'::t
ext)), ((poi.supplier_product ->> 'supplier_product_pack_quantity'::text)), ((poi.supplier_product ->> 'supplier_product_unit_purchase'::text)), poi.confirmed_quantity, sp.name, sp.identifier, (1)
   Buffers: shared hit=64032
   ->  Sort  (cost=120200.56..120200.57 rows=2 width=252) (actual time=186.714..186.718 rows=3 loops=1)
         Output: ((poi.supplier_product ->> 'supplier_product_name'::text)), poi.price, poi.created_at, ((poi.supplier_product ->> 'supplier_product_manufacturer'::text)), ((poi.supplier_product ->> 'supplier_product_packi
ng'::text)), ((poi.supplier_product ->> 'supplier_product_pack_quantity'::text)), ((poi.supplier_product ->> 'supplier_product_unit_purchase'::text)), poi.confirmed_quantity, sp.name, sp.identifier, (1)
         Sort Key: (1)
         Sort Method: quicksort  Memory: 25kB
         Buffers: shared hit=64032
         ->  Append  (cost=120195.05..120200.49 rows=2 width=252) (actual time=186.639..186.704 rows=4 loops=1)
               Buffers: shared hit=64032
               ->  Limit  (cost=120195.05..120195.06 rows=1 width=242) (actual time=186.637..186.640 rows=3 loops=1)
                     Output: ((poi.supplier_product ->> 'supplier_product_name'::text)), poi.price, poi.created_at, ((poi.supplier_product ->> 'supplier_product_manufacturer'::text)), ((poi.supplier_product ->> 'supplier_p
roduct_packing'::text)), ((poi.supplier_product ->> 'supplier_product_pack_quantity'::text)), ((poi.supplier_product ->> 'supplier_product_unit_purchase'::text)), poi.confirmed_quantity, sp.name, sp.identifier, 1
                     Buffers: shared hit=64024
                     ->  Sort  (cost=120195.05..120195.06 rows=1 width=242) (actual time=186.619..186.621 rows=3 loops=1)
                           Output: ((poi.supplier_product ->> 'supplier_product_name'::text)), poi.price, poi.created_at, ((poi.supplier_product ->> 'supplier_product_manufacturer'::text)), ((poi.supplier_product ->> 'supp
lier_product_packing'::text)), ((poi.supplier_product ->> 'supplier_product_pack_quantity'::text)), ((poi.supplier_product ->> 'supplier_product_unit_purchase'::text)), poi.confirmed_quantity, sp.name, sp.identifier, 1
                           Sort Key: poi.created_at DESC
                           Sort Method: top-N heapsort  Memory: 26kB
                           Buffers: shared hit=64024
                           ->  Nested Loop  (cost=2.04..120195.04 rows=1 width=242) (actual time=0.193..186.435 rows=273 loops=1)
                                 Output: (poi.supplier_product ->> 'supplier_product_name'::text), poi.price, poi.created_at, (poi.supplier_product ->> 'supplier_product_manufacturer'::text), (poi.supplier_product ->> 'sup
plier_product_packing'::text), (poi.supplier_product ->> 'supplier_product_pack_quantity'::text), (poi.supplier_product ->> 'supplier_product_unit_purchase'::text), poi.confirmed_quantity, sp.name, sp.identifier, 1
                                 Inner Unique: true
                                 Buffers: shared hit=64024
                                 ->  Nested Loop  (cost=1.75..120194.70 rows=1 width=423) (actual time=0.169..181.896 rows=273 loops=1)
                                       Output: poi.supplier_product, poi.price, poi.created_at, poi.confirmed_quantity, po.supplier_id
                                       Inner Unique: true
                                       Buffers: shared hit=63194
                                       ->  Nested Loop  (cost=1.31..120192.03 rows=1 width=427) (actual time=0.154..181.249 rows=273 loops=1)
                                             Output: poi.supplier_product, poi.price, poi.created_at, poi.confirmed_quantity, po.supplier_id, bp.base_product_id
                                             Join Filter: ((bp.code)::text = (poi.buyer_product ->> 'buyer_product_code'::text))
                                             Rows Removed by Join Filter: 48357
                                             Buffers: shared hit=62374
                                             ->  Nested Loop  (cost=0.87..7304.32 rows=6352 width=19) (actual time=0.037..8.088 rows=9209 loops=1)
                                                   Output: po.id, po.supplier_id, bp.code, bp.base_product_id
                                                   Buffers: shared hit=4649
                                                   ->  Index Scan using buyer_products_pkey on public.buyer_products bp  (cost=0.44..2.68 rows=1 width=15) (actual time=0.016..0.018 rows=1 loops=1)
                                                         Output: bp.id, bp.base_product_id, bp.buyer_id, bp.created_at, bp.updated_at, bp.external_id, bp.name, bp.code, bp.status, bp.source_price, bp.identifier, bp.accept_
alternative_brands, bp.id_scub, bp.internal_scub_id, bp.detailed_description, bp.buyer_product_children
                                                         Index Cond: (bp.id = 12081956)
                                                         Buffers: shared hit=4
                                                   ->  Index Scan using index_purchase_orders_on_buyer_id on public.purchase_orders po  (cost=0.43..7100.53 rows=6704 width=12) (actual time=0.012..6.880 rows=9209 loops=1)
                                                         Output: po.id, po.supplier_id, po.total_price, po.items_count, po.created_at, po.updated_at, po.confirmation_id, po.synchronized_at, po.buyer_id, po.observation, po.
sequence, po.origin_id, po.origin_type, po.external_id, po.redirect_to_legacy, po.name, po.origin_observation, po.opened_at, po.contact, po.terms_and_conditions, po.freight_type, po.minimum_purchase, po.valid_until, po.pay
ment_method_id, po.billing_address, po.uuid, po.created_by, po.created_by_info, po.pending_download, po.text_search_vector, po.request_id, po.upload_id, po.upload_id_gr
                                                         Index Cond: (po.buyer_id = bp.buyer_id)
                                                         Buffers: shared hit=4645
                                             ->  Index Scan using index_purchase_order_items_on_purchase_order_id on public.purchase_order_items poi  (cost=0.44..9.90 rows=225 width=751) (actual time=0.002..0.006 rows=5 lo
ops=9209)
                                                   Output: poi.id, poi.purchase_order_id, poi.created_at, poi.updated_at, poi.status, poi.confirmed_quantity, poi.cancellation, poi.supplier_observation, poi.opening_quantity
, poi.supplier_product, poi.buyer_product, poi.sequence_number, poi.price, poi.comments, poi.origin, poi.uuid, poi.sequence_seller_item_number
                                                   Index Cond: (poi.purchase_order_id = po.id)
                                                   Filter: (poi.created_at < '2025-02-21 16:57:59.225571'::timestamp without time zone)
                                                   Rows Removed by Filter: 0
                                                   Buffers: shared hit=57725
                                       ->  Index Only Scan using products_pkey on public.products p  (cost=0.44..2.68 rows=1 width=4) (actual time=0.002..0.002 rows=1 loops=273)
                                             Output: p.id
                                             Index Cond: (p.id = bp.base_product_id)
                                             Heap Fetches: 0
                                             Buffers: shared hit=820
                                 ->  Index Scan using idx_sp_on_id on public.suppliers sp  (cost=0.29..0.33 rows=1 width=58) (actual time=0.002..0.002 rows=1 loops=273)
                                       Output: sp.id, sp.name, sp.created_at, sp.updated_at, sp.external_id, sp.identifier, sp.identifier_kind, sp.bio_id, sp.synchronized_at, sp.bioid_synchronized_at, sp.phone, sp.plan_nam
e, sp.metadata, sp.is_external
                                       Index Cond: (sp.id = po.supplier_id)
                                       Buffers: shared hit=828
               ->  Subquery Scan on "*SELECT* 2"  (cost=0.87..5.38 rows=1 width=252) (actual time=0.058..0.060 rows=1 loops=1)
                     Output: "*SELECT* 2".name, "*SELECT* 2".price, "*SELECT* 2".expires_at, ''::text, ''::text, ''::text, ''::text, 0, ''::character varying, ''::character varying, 2
                     Buffers: shared hit=8
                     ->  Nested Loop  (cost=0.87..5.34 rows=1 width=262) (actual time=0.054..0.055 rows=1 loops=1)
                           Output: bp_1.name, bp_1.source_price, bp_1.created_at, ''::text, ''::text, ''::text, ''::text, 0, ''::character varying, ''::character varying, 2
                           Inner Unique: true
                           Buffers: shared hit=8
                           ->  Index Scan using idx_bp_on_id_source_price on public.buyer_products bp_1  (cost=0.43..2.67 rows=1 width=66) (actual time=0.013..0.013 rows=1 loops=1)
                                 Output: bp_1.id, bp_1.base_product_id, bp_1.buyer_id, bp_1.created_at, bp_1.updated_at, bp_1.external_id, bp_1.name, bp_1.code, bp_1.status, bp_1.source_price, bp_1.identifier, bp_1.accept_
alternative_brands, bp_1.id_scub, bp_1.internal_scub_id, bp_1.detailed_description, bp_1.buyer_product_children
                                 Index Cond: (bp_1.id = 12081956)
                                 Buffers: shared hit=4
                           ->  Index Only Scan using products_pkey on public.products p_1  (cost=0.44..2.68 rows=1 width=4) (actual time=0.005..0.005 rows=1 loops=1)
                                 Output: p_1.id
                                 Index Cond: (p_1.id = bp_1.base_product_id)
                                 Heap Fetches: 0
                                 Buffers: shared hit=4
 Query Identifier: 273579094919034765
 Planning:
   Buffers: shared hit=129
 Planning Time: 3.160 ms
 JIT:
   Functions: 34
   Options: Inlining false, Optimization false, Expressions true, Deforming true
   Timing: Generation 1.963 ms, Inlining 0.000 ms, Optimization 0.883 ms, Emission 19.683 ms, Total 22.528 ms
 Execution Time: 209.321 ms
(82 rows)

Just like Oracle Database, PostgreSQL uses indentation, represented by the -> symbol, to indicate the order in which operations are executed, without assigning a sequential identifier to each step. The greater the indentation before the ->, the more internal (or deeper) the step is in the execution plan. This means the most indented steps are executed first.

With the execution plan in text format, we can use the two tools presented above to run the analysis and identify potential bottlenecks in query execution. Next, we will explore both approaches and show how to spot the main optimization opportunities in the SQL.


We will take the text-format execution plan generated earlier and submit it to the Explain Depesz tool, which presents the analysis in tabular format. You can access it through the following link: Explain Depesz.
https://explain.depesz.com/

Steps to view the execution plan in Explain Depesz:

After submitting the execution plan, note the main bottlenecks identified in the demonstration below. They are highlighted in orange and red, indicating the most expensive operations. In addition, the highlighted excerpts represent query clauses that can be optimized to improve performance.

Nested Loop (cost=1.31..120,192.03 rows=1 width=427) (actual time=0.154..181.249 rows=273 loops=1)
Output: poi.supplier_product, poi.price, poi.created_at, poi.confirmed_quantity, po.supplier_id, bp.base_product_id
Join Filter: ((bp.code)::text = (poi.buyer_product ->> ‘buyer_product_code’::text))
Rows Removed by Join Filter: 48,357
Buffers: shared hit=62,374

📌 What does this mean?

🚨 Problem identified:

🔹 Join details and the filter problem





Join Filter: ((bp.code)::text = (poi.buyer_product ->> ‘buyer_product_code’::text))
Rows Removed by Join Filter: 48,357

📌 What does this mean?

🚨 Problem identified:

Buffers: shared hit=62,374

📌 O que isso significa?

🔹 How do we optimize this part of the plan?

Now that we know the Nested Loop Join and the JSON filter are the main problems, we can suggest improvements:

1️⃣ Create an index to improve the JSON comparison

The (bp.code)::text = (poi.buyer_product ->> 'buyer_product_code'::text) comparison is very expensive, since PostgreSQL has to process the JSON data before comparing it.

🚀 Solution: Create a functional index on the JSON

CREATE INDEX idx_poi_buyer_product_code ON purchase_order_items ((buyer_product ->> 'buyer_product_code'));

CREATE INDEX idx_poi_purchase_order_id_created_at ON purchase_order_items (purchase_order_id, created_at DESC);

📌 Expected impact:

📌 Problems identified:

  1. The Join Filter is discarding too many rows (48,357 removed).
  2. The Nested Loop Join is not efficient for large data volumes.
  3. There is no efficient index for JSON lookups.
  4. There is no composite index on purchase_order_items for fast joins.

🚀 Suggested improvements:

✅ Create an index to optimize the JSON lookup.
✅ Use a Hash Join instead of Nested Loop.
✅ Create a composite index to improve the joins.

Improving the analysis and using the EXPLAIN DALIBO tool

Now that we have identified the bottlenecks and proposed solutions, let’s approach the problem from a new perspective using the EXPLAIN Dalibo tool.

https://explain.dalibo.com



Note that the Nested Loop box represents the query’s main bottleneck, and the “Join Filter” is tied to the ((bp.code)::text = (poi.buyer_product ->> 'buyer_product_code'::text)) condition, which indicates an inefficient join that directly impacts query performance.


Conclusion

In this article, we explored two approaches to analyzing and optimizing a SQL query in PostgreSQL:

  1. EXPLAIN ANALYZE → Provides a detailed report on query execution, helping identify costs, execution time, and key statistics.
  2. EXPLAIN Dalibo → Presents a visual, intuitive format, making it easier to spot the query’s critical points, such as Nested Loops, inefficient filters, and unnecessary sorts.

We also learned how to identify bottlenecks in query execution, such as the inefficient Join Filter and manual sorting without index support. From there, we applied strategic indexes and suggested switching join algorithms to improve performance.

The main takeaway here is that before optimizing a query, it is essential to understand how PostgreSQL executes it, locate the bottlenecks, and apply surgical adjustments, such as creating indexes and choosing the most efficient join type. 🚀