I'm struggling to get the below query to perform well. It's doing a sequential scan on the children table even though there's indexes on the join columns. Any ideas?

EXPLAIN ANALYZE SELECT "children".* from "children"
    INNER JOIN "parents" ON "parents"."id" = "children"."parent_id" 
    WHERE "parents"."partner_pk" = 'partner' 

Hash Join  (cost=55541.97..140831.44 rows=20804 width=168) (actual time=366.809..1851.311 rows=21215 loops=1)
  Hash Cond: (children.parent_id = parents.id)
  Buffers: shared hit=47476
  ->  Seq Scan on children  (cost=0.00..66250.30 rows=2152130 width=168) (actual time=0.007..1012.502 rows=2215906 loops=1)
        Buffers: shared hit=44729
  ->  Hash  (cost=55282.99..55282.99 rows=20718 width=4) (actual time=17.496..17.496 rows=21215 loops=1)
        Buckets: 4096  Batches: 1  Memory Usage: 746kB
        Buffers: shared hit=2747
        ->  Bitmap Heap Scan on parents  (cost=876.99..55282.99 rows=20718 width=4) (actual time=2.339..10.817 rows=21215 loops=1)
              Recheck Cond: ((partner_pk)::text = 'partner'::text)
              Heap Blocks: exact=2614
              Buffers: shared hit=2747
              ->  Bitmap Index Scan on index_parents_on_partner_pk  (cost=0.00..871.81 rows=20718 width=0) (actual time=1.992..1.992 rows=21215 loops=1)
                    Index Cond: ((partner_pk)::text = 'partner'::text)
                    Buffers: shared hit=133
Planning time: 0.280 ms
Execution time: 1855.458 ms

CREATE TABLE "public"."parents" (
    "id" int4 NOT NULL DEFAULT nextval('parents_id_seq'::regclass),
    "email" varchar NOT NULL,
    "partner_id" int4,
    "partner_pk" varchar
ALTER TABLE "public"."parents" OWNER TO "bark";
CREATE UNIQUE INDEX "index_parents_on_LOWER_email" ON "public"."parents" USING btree("lower(email::text)" ASC NULLS LAST);
CREATE INDEX "index_parents_on_partner_id" ON "public"."parents" USING btree(partner_id ASC NULLS LAST);
CREATE INDEX "index_parents_on_partner_pk" ON "public"."parents" USING btree(partner_pk ASC NULLS LAST);

CREATE TABLE "public"."children" (
    "id" int4 NOT NULL DEFAULT nextval('children_id_seq'::regclass),
    "first_name" varchar,
    "last_name" varchar,
    "parent_id" int4,
    "email" varchar,

CREATE INDEX "index_children_on_parent_id" ON "public"."children" USING btree(parent_id ASC NULLS LAST);
  • What version of PostgreSQL are you using? Sep 19, 2018 at 22:22
  • What are the values of partner_pk? Sep 19, 2018 at 22:26
  • @EvanCarroll 9.4.15. String values like "example.com", "domain.com", etc. Sep 19, 2018 at 23:16
  • Can you do the EXPLAIN (ANALYZE, BUFFERS), preferably with track_io_timings turned on. Also, is this fully reproducible, or will it run much faster if you immediately execute the query again?
    – jjanes
    Sep 20, 2018 at 0:04
  • @jjanes It'll go faster on subsequent loads, but still show the sequential scan in the query plan. Updated with buffers output. Sep 20, 2018 at 2:57

2 Answers 2


The obvious alternative to the seq scan on the child table would be the nested loop in which it has to probe the child table by index 20,718 separate times. (Actually 21,215 times, but of course the planner doesn't know that at the time it makes its decision). Each one of those probes is going to go to an assumedly random leaf page in the index, and then follow up to a random spot in the child table itself, triggering a bunch of random IO. If all that of that data is already found in the cache, this will be a good plan. But if it actually has to hit the disk for each one this is going to be ungodly slow (especially since your disk seems to be powered by gerbils--your pre-edit plan where it took 22 seconds to seq scan the child table is simply horrible).

If you want to see the alternative plan, you can set enable_hashjoin = off before executing the query. This is generally not a setting you would want to do in production, it is more for exploratory purposes. But as a last resort, you could make this setting change locally just for running this query, then reset it.

If you like the nested loop because most of your data actually is in memory most of the time, you can signal this to the planner by reducing the value of "random_page_cost" to something closer to 1. In my hands, lowering from the default of 4 down to 1.4 was enough to flip the plan from the seq scan to the nested loop. If most of your data is always in cache (not just the child table and its index) then it might be reasonable to make a global change to this parameter. If you do that, however, you should have a plan to pre-warm your database in case you reboot the machine (such as this). Getting the data back into cache the natural way is likely to be slow and painful.

  • This is great. Thanks so much for the insight. I didn't know about those settings and associated values and how they contributed. Stepping back, it seems that a structure change is probably a better solution for us, but I learned a lot in the process ;) Sep 20, 2018 at 17:49

In your table declaration you have partner_pk as varchar. That should not be varchar. In fact, this whole table structure could be better defined as a single-table hierarchy.

  • Use Identity Columns
  • Use a custom domain to store email
  • Use a single-table hierarchy

Like this,

CREATE DOMAIN email AS citext
  CHECK ( value ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' );

CREATE TABLE public.people (
    parent_people_id  int     REFERENCES people,
    first_name        text,
    last_name         text,
    email             email   NOT NULL,
    partner_id        int
CREATE INDEX ON people(parent_people_id);
CREATE INDEX ON people(email);

See also

  • Thanks for the info on email. We're managing that part ok, but will not your suggestions. Given what we have and without changing the structure of the entire table, do you have any suggestions? Sep 19, 2018 at 23:18

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.