As with everything, it depends. Postgres JSON[B] columns support GIN indices (technically they also support BTree and Hash, but using them can be confusing), which supports a handful of operators [0]. You can also do partial indices with these (e.g. CREATE INDEX foo_data_pidx ON foo (data) WHERE data ->> 'is_active' = 'true'), though that can then create confusion for using it, because while the index predicate accepts / requires equality (SELECT ... WHERE data ->> 'is_active' = 'true'), the rest of the object requires an operator like @> (contains), so it can be awkward to write. This is the BTree expression index I stated at the beginning could be confusing. I have no idea if ORMs / query builders get this right or not.
The other major problem is lack of statistics. JSON[B] is opaque to Postgres; it has no idea about the distribution of values within an object. This starts to become very obvious if you're doing JOINs and have predicates from the JSON object. Take for example geographical information: maybe you want to find some number of customers from Delaware who have onboarded in the last week. If that geographical information is in a JSON object, and you have 5,000,000 rows, Postgres has no idea how many rows it should expect, so it may not choose the optimal join strategy. The same is true in the opposite direction - it's probable that you have more customers in California than you do in Delaware purely based on relative population, but because the planner doesn't know that, it's unlikely to choose the optimal strategy.
To be fair, for correlated columns (extending the above example, think of postal_code and state), the planner can also make the same mistake in underestimations, because by default it assumes the columns are uncorrelated. This can be solved with extended statistics [1], where you explicitly tell it which column[s] are correlated, and in what manner.
I created a table in PG18 on my Mac, with 5,000,000 rows of fake customers (various columns like names, geo information, email, opaque ids, integer id, date times, etc.), and another where most of it was shifted into a JSONB column, exact same information, same ordering of rows. In general, simple point lookups on the scalar table took 0.1 msec, as you'd expect; the JSONB table could at best do 2.5 msec, if and only if the correct operator was used. Use the wrong operator, and it still works, but it reverts to a parallel sequential scan, which took ~175 - 400 msec, depending on the specific operator being used. For joins, it was 20-30% slower with an expression index, and 20-350% (yes, large range) slower with a GIN index.
JSON[B] also struggles with memory usage during queries, and WAL generation. Since the entire object has to be extracted, intermediate query results can get quite large; if you don't have a large-enough `work_mem`, this slows things down (the same is true for scalars, of course, but JSON objects tend to be larger, if nothing else due to repeated keys). If you ever have to UPDATE a JSON[B] object, that's a full re-write, and an update to any GIN indices it may have. I measured 5.5 sec vs. 2 sec for updating 1 attribute on 100K rows, and 831 MB WAL vs. 227 MB WAL generation. Those times would be much, much more if this wasn't on a fast local NVMe drive, as well.
There's also TOAST/DE-TOAST [2] time, which I didn't measure, because my objects were small. With a large enough value (it doesn't have to be JSON, but it usually is) - by default, 2 KB - Postgres will first attempt to compress, and then shift those values out to a separate table, vaguely similar to InnoDB's DYNAMIC page format. That means when you're querying with predicates on the TOAST'd object, it now has to do another indirect lookup, retrieve the page, and check the predicate. At scale, this can severely slow things down.
Finally, and most importantly IMO, guaranteeing invariants is next to impossible with JSON objects. Was that field left NULL when it shouldn't have been? Did the email field actually contain an email address? Hopefully your application is checking everything rigorously, and no one has changed its logic from what was intended.
0: https://www.postgresql.org/docs/current/datatype-json.html#J...
1: https://www.postgresql.org/docs/current/sql-createstatistics...
2: https://www.postgresql.org/docs/current/storage-toast.html
Oh yeah I would not do things like joins but for simple filtering I have found that it works very well. Of course if I knew I needed to filter on that value beforehand it would not be in a json column but I found that it's sufficient enough that it beats doing a re-architecture or dual writes if you realize you actually do need to do a filter after the fact