Category Archives: SQL

The definitive answer to the ID vs TABLE_ID debate

This is a generated image showing two tables with surrogate keys, one with the surrogate key named ID and another with the surrogate key named TABLE_ID. The is a box below the tables with the text "ID vs TABLE_ID".

Few debates in database design spark as much disagreement as what to name a surrogate key. There has been a debate over the years about the name of that column, and it generally falls into two choices:

  1. Every table that has a surrogate key should name the column ID.
  2. Every table that has a surrogate key should name the column TABLE_ID.

I have very strong opinions about this debate, born out of over 35 years of building database-backed business applications.

My sincere belief is that every surrogate key should be prefixed with the table name. No, I’m not going to fall for the straw man argument that any of the other columns in a table should be prefixed with the table name. The surrogate key is special.

The ID folks try to make some of the following arguments:

  1. It is short and universal. You don’t have to think as much.
  2. If you are using an ORM (Object-Relational Mapping framework like Hibernate for example), you don’t have to do the incredibly simple thing of updating the ORM to expect table_id instead of just id, because most ORMs default to just ID. Of course, they don’t say it that way; they just say “Our ORM expects ID.”.
  3. We already have tables that just use ID, so we should stick to that standard.

And.. That’s it. Now, sometimes folks will talk about already having to prefix column names in select clauses, and employee.employee_id is redundant, but the reality is that you are almost certainly going to alias the employee table name as either e or maybe emp, you’re never really going to type employee.employee_id.

So, why are the ID folks wrong?

To me, the biggest, most fundamental thing is this:

You have a thing in the database, and you are calling it two different names depending on where it is located

If we have an employee table with an ID column as a surrogate key, whenever we use that surrogate key in another table as a foreign key, we typically put the table name in front of it. So ID in the employee table, and then EMPLOYEE_ID as a foreign key in any table related to the employee table.

If we call this thing EMPLOYEE_ID in the employee table and EMPLOYEE_ID whenever we use it as a foreign key (and yes, I am aware this thing could have another name when used in a different context, for example, MANAGER_ID), then it has the same name everywhere it is used.

It also goes the other way, in that you have hundreds of things named ID, but they are all different things in different tables.

This puts to rest the “It’s short and universal, you don’t have to think as much” argument because you end up thinking more every time you access the table joined to another table.

I strongly believe that the ID to EMPLOYEE_ID naming convention is a cognitive tax that must be paid over and over again.

However, there are a host of other reasons why table_id is the right way to go…

ID as a surrogate key means you are unable to join tables with a USING clause

I totally understand that some people don’t like the USING clause, but I find it very nice.

select ...
  from department d
  join employee e using(department_id);

The above is much cleaner, contains fewer opportunities for mistakes, and is easier to read compared to the below.

select ...
  from department d
  join employee e on d.id = e.department_id;

There are so many ways to make mistakes in the 2nd statement compared to the first statement. Now make this an 8-way table join, and the fun just compounds!

Clarity in queries

Surrogate key names now carry meaning without qualification, which is great in SQL, views, etc.

Self-joins are clearer

where manager_id = employee_id

reads better than

where manager_id = id

Bridge tables read naturally

Associative tables like employee_project(employee_id,project_id, ...) are self-documenting and enable USING clauses in both directions.

Grepability/searching/diagnostics is much easier

Searching logs or code for where a given entity flows is much harder if every table has a column named ID. Sometimes ID means EMPLOYEE_ID, sometimes ID means DEPARTMENT_ID, etc.

REST APIs and JSON payloads are going to read better

Your API contracts and/or JSON payloads are probably going to use EMPLOYEE_ID and DEPARTMENT_ID anyway.

But wait, what about our tables that already use ID?

We’ve already discussed the cognitive tax when using ID, and, given the choice, I’d remove that cognitive tax on all new tables in the database. With old tables that used ID, there are some choices:

  1. Bite the bullet and do the rename of the column, and then update all your applications (APEX makes this pretty easy to find and replace those references) and source code (ALL_SOURCE & ALL_VIEWS). This could be a big project, but the tools are all there. You could do a big bang approach and do everything at once, or do it over time, table by table.
  2. Create a view on top of the legacy tables that does the rename of the ID column to TABLE_ID, and have new applications and code use the view.
  3. Rename the table and the column, and then create a view on top with the old table name that maintains the legacy ID name.

In conclusion

The surrogate key naming decision may seem small at first, but in practice, it touches every query, every log, every API contract, and more. Prefix your surrogate keys (and ONLY your surrogate keys). Your future self, your team, and your database will thank you.


Finding Unindexed Foreign Keys in Oracle, now with EVEN BETTER index creation DDL!

It all started here: https://carsandcode.com/2023/07/27/finding-unindexed-foreign-keys-in-oracle/

It got better here: https://carsandcode.com/2023/09/01/finding-unindexed-foreign-keys-in-oracle-now-with-index-creation-ddl/

But now, it’s even better!

Changes:

  • A comment section at the front.
  • Formatting changes.
  • Better table aliases.
  • Indexes are now owned by the table owner rather than the DBA running the script.
  • All missing indexes are created in a single tablespace named “missing_foreign_key_indexes”. Obviously, this tablespace needs to exist for the statements to work. You might want to change this clause to use “your user’s index tablespaces”, and you should drop this if you are on Oracle Autonomous on Oracle Cloud, since you only get one tablespace for your stuff and everything will automatically go to it.
  • There are suggestions for making shorter index names if needed/wanted.
  • The “local” clause was added for partitioned tables.
  • You can optionally require validated foreign keys, valid indexes, and visible indexes. Today’s invalid or invisible index can turn into tomorrow’s index, so I left the default to show all indexes.
  • We are now sorting numerically.
  • Change “select *” into “select username” for the not in clause that eliminates users.
  • Added a where clause to find missing indexes by default.

Again, here’s the formatted code that doesn’t look great, but if you copy it, it should be formatted as it is in the above picture.

-- Created by Rich Soule of Talan's Oracle Group in collaboration with Lance Eaton.
-- 
-- Notes:
--   * Can ignore unusable/invisible/bitmap indexes; prefers NORMAL (and function-based NORMAL) b-trees
--   * Emits LOCAL for partitioned child tables
--   * change 'missing_foreign_key_indexes' tablespace to taste (or drop on Autonomous)
--   * if you want to shorten the index names, you can replace the two lines with comments below with something like: 'missing_fk_index'||rownum
--   * designed to be run by a DBA with access to the DBA views, but can also be run by a regular user by replacing the dba_ views with 
--     all_ views or user_ views (search and replace dba_ with all_ or user_)

    with owner_exclusion_list as (   select username from dba_users where oracle_maintained = 'Y'
                           union all select 'ORDS_METADATA'    from dual
                           union all select 'ORDS_PUBLIC_USER' from dual )
       , constraint_column_list as ( select owner
                                          , table_name
                                          , constraint_name
                                          , listagg(column_name, ', ') within group (order by position) as constraint_column_list
                                       from dba_cons_columns
                                       join dba_constraints using (owner, table_name, constraint_name)
                                      where constraint_type = 'R'
                                        and status = 'ENABLED'
                                     -- and validated = 'VALIDATED' -- uncomment to require validated fks
                                        and owner not in (select username from owner_exclusion_list)
                                   group by owner, table_name, constraint_name )
       , index_column_list as (      select di.owner
                                          , di.table_name
                                          , di.index_name
                                          , listagg(dic.column_name, ', ') within group (order by dic.column_position) as index_column_list
                                       from dba_indexes di
                                       join dba_ind_columns dic on (dic.index_owner = di.owner and dic.index_name  = di.index_name)
                                      where di.owner not in (select username from owner_exclusion_list)
                                     -- and di.status     = 'VALID'   -- uncomment to require valid indexes
                                     -- and di.visibility = 'VISIBLE' -- uncomment to require visible indexes
                                        and di.index_type in ('NORMAL','FUNCTION-BASED NORMAL')
                                   group by di.owner, di.table_name, di.index_name )
       , foreign_key_index_query as (select decode(icl.table_name, null, 'Missing', 'Exists')                      as index_existence
                                          , dt.num_rows                                                            as last_analyzed_row_count_number
                                          , to_char(dt.num_rows, '999,999,999,999,999')                            as last_analyzed_row_count 
                                          , dt.last_analyzed           
                                          , ccl.owner                                                              as table_owner
                                          , ccl.table_name           
                                          , ccl.constraint_name                                                    as foreign_key_name
                                          , ccl.constraint_column_list                                             as foreign_key_column_list
                                          , coalesce(icl.index_name,        '*** Missing Index ***')               as index_name 
                                          , coalesce(icl.index_column_list, '*** Missing Index ***')               as index_column_list
                                          , decode(icl.table_name, null,'create index "'||ccl.owner||'".'||
                                                                        lower(ccl.table_name||'_foreign_key_index_on_'|| -- Shorten these two lines to have
                                                                        replace(replace(ccl.constraint_column_list,',','_'),' '))|| -- smaller index names
                                                                        ' on "'||ccl.owner||'"."'||ccl.table_name||'"('||
                                                                        replace(replace(ccl.constraint_column_list,',','","'),' ')||')'||
                                                                        decode(dt.partitioned, 'YES', ' local', '')||
                                                                        ' tablespace missing_foreign_key_indexes;'
                                                                       ,'*** supporting index already exists ***') as create_index_ddl
                                       from constraint_column_list ccl
                                       join dba_tables dt on (dt.owner = ccl.owner and dt.table_name = ccl.table_name)
                                  left join index_column_list icl on (     icl.owner = ccl.owner and icl.table_name = ccl.table_name
                                                                       and icl.index_column_list like ccl.constraint_column_list || '%' ))
  select index_existence
       , last_analyzed_row_count
       , last_analyzed
       , table_owner
       , table_name
       , foreign_key_name
       , foreign_key_column_list
       , index_name
       , index_column_list
       , create_index_ddl
    from foreign_key_index_query
   where index_existence = 'Missing'  -- comment to see both Exists & Missing
order by last_analyzed_row_count_number desc nulls last, table_owner, table_name, foreign_key_column_list;

Random account locks on Oracle 23ai and a potential solution for Oracle APEX

TLDR: I’ve been running into an issue where my Oracle Base Database on Oracle Cloud running Oracle 23ai appears to be ‘automatically locking accounts at random times’. To potentially prevent one of these random locks from stopping APEX from working, try this unsupported but working adjustment to your APEX_PUBLIC_USER account: alter user apex_public_user account unlock no authentication;

The background: My database is what is currently called on Oracle Cloud, an “Oracle Base Database”. Unlike the Oracle Autonomous Database, where you get a pluggable database in a container database that someone else manages, here you get full access to the database file system and full access to everything about the database (root container, full sys user access, etc.). I say “currently called” because we actually put this database on Oracle Cloud way back in Sept of 2021. That’s when this database was migrated from an on-premises Oracle Database Appliance to Oracle Cloud.

Oracle Cloud has changed a bunch since then, but overall, I couldn’t be happier with the migration. With Oracle Base Database, you “let” Oracle manage the software locations and database locations (Oracle uses Automatic Storage Management for the database and fast recovery area storage). Patches and upgrades (we started with 19c, but are now on 23ai) are straightforward and controlled at your own pace, implemented by simple choices in the Oracle Cloud UI.

For many years, this database “just worked”. The business ran its processes, and the APEX application we built for them just did its thing. On July 22nd, I got a call from the business saying “APEX isn’t working”. When I went and looked, the APEX_PUBLIC_USER account was locked. This is strange because there wasn’t a reason for the account to be locked. Nobody did anything. The database profile for the APEX_PUBLIC_USER has a password life time of unlimited, so it wasn’t a profile thing. I unlocked the account, APEX started working again, and life was good. An investigation into the unified audit trail didn’t show anything. This was a “mystery”. Anyone in tech would agree that a mystery isn’t good.

On August 11th, I got the same call. Again, the APEX_PUBLIC_USER account was locked. I again unlocked it. This time I did a bigger investigation with a coworker. He’s been struggling with the same random locking behavior for the APEX_PUBLIC_USER in his DEV, TEST, and PROD environments for the last 4 months (he’s had many Oracle SRs open and closed on this while he’s been bounced around various teams within Oracle, and his random locks have happened much more frequently than mine). As we looked at things, we realized that there is an amount of correlation between database patches being applied and accounts getting locked. It’s not exact, but here are some of the queries that we looked at:

  select cdb$name as container -- Awesome hidden colum on CDB_ views!
       , target_build_description
       , action_time 
    from cdb_registry_sqlpatch 
order by action_time desc;

  select username
       , cdb$name as container
       , lock_date
       , last_login
       , created
       , cu.* 
    from cdb_users cu 
order by cu.lock_date desc nulls last;

select cdb$name as container
     , cp.* 
  from cdb_profiles cp
 where resource_name = 'INACTIVE_ACCOUNT_TIME';

Obviously, if you don’t have access to the root container, you can change the above queries to use the DBA views in your own pluggable (or non-container) database if you eliminate the pdb_name column.

Something very interesting was that there were a LOT of accounts getting locked at the “same time”, but that time was different for different pluggable databases in the same container database.

I’ve got two “opportunities for future enhancement” logged against the APEX product and APEX documentation. This is the current slide in my latest (award-winning!) APEX & ORDS for DBAs and System Admins presentation (an earlier version of this can be found on YouTube).

A while back, I had shared that with my coworker, and he had implemented it in his dev and test environment:

alter user apex_public_user account unlock no authentication;

Since implementing this, he has not had the locking issue for the APEX_PUBLIC_USER his 23ai environments.

I went ahead and implemented this in DEV, TEST, and PROD. We’ll see what happens, and if any of the SRs my coworker has filed with Oracle Support get an actual resolution, I’ll update this post!


ANSI vs. Oracle Proprietary SQL

On Tuesdays Cary Millsap runs a meeting for Method-R customers to chat about Oracle stuff. Often he has something prepared, but this Tuesday he didn’t. Doug Gault decided to share an image that finally helped him get his head around the ANSI SQL syntax. Doug has been around the Oracle world for a long time but he’s always been able to work with Oracle proprietary SQL so he never really learned the ANSI SQL syntax. Recently he got assigned to a project where ANSI SQL is mandated so he had to get everything straight in his head. He shared an image that he had created from some training and we all took a look at it. Me, being me, I immediately jumped in with what I thought would be improvements to the image. I was challenged to come up with a better image, and so, I created the below.

My hope is that this will help some folks move away from the horrible (in my opinion) Oracle propriety SQL syntax to the totally awesome ANSI SQL syntax. I think the Oracle syntax is horrible because where clauses in queries end up doing two things; joining table AND filtering rows. With the ANSI syntax, join clauses join tables and where clauses only filter rows.

A note on the above: I used the preferred USING syntax to join tables for the ANSI queries:

join using (deptno)

instead of the ON syntax to join tables

join on e.deptno = d.deptno

I believe this is easier to read and understand and, in general, less code is better code, and this is smaller. If you use the USING syntax just note that you no longer associate the column with one table or another in the other clauses (like SELECT or WHERE) but instead leave it unconstrained. For example:

select deptno, e.ename, d.dname
  from emp e
 join dept d using (deptno)
where deptno > 20;

If you were to qualify the DEPTNO column in either the select clause or the where clause (d.deptno for example) you’d get an ORA-25154: column part of USING clause cannot have qualifier message.

Let me know if this is helpful for you!


Finding Unindexed Foreign Keys in Oracle, now with index creation DDL!

2025-08-13 Update: It’s now even better, use this post instead: https://carsandcode.com/2025/08/13/finding-unindexed-foreign-keys-in-oracle-now-with-even-better-index-creation-ddl/

Previously I wrote a better query for finding unindexed foreign key indexes. Today Lance (one of the great folks at Insum) and I went through the query in a code review and we came up with a few improvements.

The first thing that bothered me was the ‘possible missing index’ result. I did some testing, and I couldn’t come up with a situation where I’d get a missing index result, but the index really was there, so I made the results just say “Missing” if I couldn’t find the index.

Lance and I also cleaned up a few column names to make things easier to understand. For example, I previously had a column named foreign_key_index that had either “Exists” or “Missing” which really wasn’t a foreign_key_index, but instead a flag showing the status of the foreign key index. This was renamed to “index_existence”. Other columns got better names too.

Lance made a suggestion to take the final query and make it a CTE (Common Table Expression, sometimes called a “with clause”), and then create a new simple final query that just selected each column name from the CTE making it very easy for folks to comment out columns if they are not interested in them, or to re-order the columns if they wanted.

We also added another column to the report with the DDL that would create an index that would eliminate the missing foreign key index.

Some notes on that DDL statement:

  • I’m a very big fan of naming things what they are so my index name ends up being quite long. You’ll almost certainly need to have long object names enabled in your database if you want to use the index name as is. If you need a shorter name, you can just modify the query.
  • If the query returns multiple foreign key indexes to create on a single table, take a look and see if one of the multicolumn indexes would also eliminate the need for some of the indexes that have fewer columns. Remember though, that the columns need to be in the right order. So if your table has a foreign key constraint on columns c1, c2, c3, and c4 that points to one table and then another foreign key constraint just on only c4 that points to another table, an index on c1, c2, c3, and c4 in that order will be able to be used for foreign key constraint number one, but not for foreign key constraint number two.
  • I always use lowercase to write all my statements as science says that this is easier for humans to read. All lowercase letters are also much easier to write. When my statements are run, the data dictionary is always populated in uppercase because I never double-quote any of the schema, table, or column names. Some people (and this is never recommended) actually double-quote table and column names and put names in lowercase or mixed-case in the data dictionary which means that every statement that accesses those objects must reference those objects as double-quoted identifiers. Amazingly you can also create schemas in lowercase too! I would never, never do this as you’re just making all your code harder to read and write. I debated on allowing for this craziness, but in the end decided that, yeah, it’s probably better to just help the misguided folks who do this so the owner, table name, and column name(s) in the DDL statement are all double-quoted identifiers as they are stored in the data dictionary. The DDL isn’t as pretty, but it works for more people.

The query could also be just a bit more efficient by eliminating the first column, index_existence. The DDL column now is effectively the same information. I left it because I think it adds some value and would make filtering a bit easier to read.

I’ve formatted the query below to be easy to read when displayed with at least 144 columns of width (see the image above). Below the code doesn’t look as pretty because there are not 144 columns of width in the text block.

To that end, here’s the “better” query:

with owner_exclusion_list as (          select username from dba_users where oracle_maintained ='Y'
                                   union all select 'ORDS_METADATA' from dual
                                   union all select 'ORDS_PUBLIC_USER' from dual )
        , constraint_columns as ( select owner
                                       , table_name
                                       , constraint_name
                                       , listagg(column_name, ', ') within group(order by position) as constraint_column_list
                                    from dba_cons_columns 
                                    join dba_constraints using (owner, table_name, constraint_name)
                                   where constraint_type = 'R' -- R = Referential Foreign Key Constraint
                                     and owner not in (select * from owner_exclusion_list)
                                group by owner, table_name, constraint_name )
        , index_columns as ( select index_owner as owner
                                  , table_name
                                  , index_name
                                  , listagg(column_name, ', ') within group(order by column_position) as index_column_list
                               from dba_ind_columns 
                              where index_owner not in (select * from owner_exclusion_list)
                           group by index_owner, table_name, index_name )
        , foreign_key_index_query as ( select decode(ic.table_name, null, 'Missing'
                                                                        , 'Exists')                                   as index_existence
                                            , to_char(dbat.num_rows, '999,999,999,999,999')                           as last_analyzed_row_count
                                            , dbat.last_analyzed
                                            , cc.owner                                                                as table_owner
                                            , cc.table_name
                                            , constraint_name                                                         as foreign_key_name
                                            , constraint_column_list                                                  as foreign_key_column_list
                                            , coalesce(index_name, '*** Missing Index ***')                           as index_name
                                            , coalesce(index_column_list,'*** Missing Index ***')                     as index_column_list
                                            , decode(ic.table_name, null, 'create index '||lower(cc.table_name||'_foreign_key_index_'||
                                                                          replace(replace(constraint_column_list,',','_'),' '))||' on "'|| 
                                                                          cc.owner || '"."'||cc.table_name||'"("'||
                                                                          replace(replace(constraint_column_list,',','","'),' ')||'");'
                                                                        , '*** Supporting index already exists ***' ) as create_index_ddl
                                         from constraint_columns cc
                                         join dba_tables dbat on ( dbat.owner = cc.owner and dbat.table_name = cc.table_name)
                                    left join index_columns ic on (    cc.owner = ic.owner and cc.table_name = ic.table_name 
                                                                   and ic.index_column_list like cc.constraint_column_list || '%' ) )
  select index_existence
       , last_analyzed_row_count
       , last_analyzed
       , table_owner
       , table_name
       , foreign_key_name
       , foreign_key_column_list
       , index_name
       , index_column_list
       , create_index_ddl
    from foreign_key_index_query
order by last_analyzed_row_count desc nulls last, table_owner, table_name, foreign_key_column_list;

2024-02 Update: As my friend Anton Nielsen pointed out, not everybody has DBA privs… If you don’t, just do a search and replace of “dba_” with “all_”. Then you can run this as a non-privileged user and you’ll be sure to capture missing indexes in your own schema and any other schema you can see.

2024-03 Update: Jeff Smith gave the Insum team an overview of the SQL Developer extension for VSCode. His blog post shows how to add snippets to VSCode SQL Developer. I thought I’d give it a shot and created the following which works like a charm. Using “ffk”, for “Find Foreign Keys” will give you the above query if you put the following into your oracle_sql.json file.

"ffk": {
"prefix": "ffk",
"body": [
"with owner_exclusion_list as ( select username from dba_users where oracle_maintained ='Y'",
" union all select 'ORDS_METADATA' from dual",
" union all select 'ORDS_PUBLIC_USER' from dual )",
" , constraint_columns as ( select owner",
" , table_name",
" , constraint_name",
" , listagg(column_name, ', ') within group(order by position) as constraint_column_list",
" from dba_cons_columns ",
" join dba_constraints using (owner, table_name, constraint_name)",
" where constraint_type = 'R' -- R = Referential Foreign Key Constraint",
" and owner not in (select * from owner_exclusion_list)",
" group by owner, table_name, constraint_name )",
" , index_columns as ( select index_owner as owner",
" , table_name",
" , index_name",
" , listagg(column_name, ', ') within group(order by column_position) as index_column_list",
" from dba_ind_columns ",
" where index_owner not in (select * from owner_exclusion_list)",
" group by index_owner, table_name, index_name )",
" , foreign_key_index_query as ( select decode(ic.table_name, null, 'Missing'",
" , 'Exists') as index_existence",
" , to_char(dbat.num_rows, '999,999,999,999,999') as last_analyzed_row_count",
" , dbat.last_analyzed",
" , cc.owner as table_owner",
" , cc.table_name",
" , constraint_name as foreign_key_name",
" , constraint_column_list as foreign_key_column_list",
" , coalesce(index_name, '*** Missing Index ***') as index_name",
" , coalesce(index_column_list,'*** Missing Index ***') as index_column_list",
" , decode(ic.table_name, null, 'create index '||lower(cc.table_name||'_foreign_key_index_'||",
" replace(replace(constraint_column_list,',','_'),' '))||' on \"'|| ",
" cc.owner || '\".\"'||cc.table_name||'\"(\"'||",
" replace(replace(constraint_column_list,',','\",\"'),' ')||'\");'",
" , '*** Supporting index already exists ***' ) as create_index_ddl",
" from constraint_columns cc",
" join dba_tables dbat on ( dbat.owner = cc.owner and dbat.table_name = cc.table_name)",
" left join index_columns ic on ( cc.owner = ic.owner and cc.table_name = ic.table_name ",
" and ic.index_column_list like cc.constraint_column_list || '%' ) )",
" select index_existence",
" , last_analyzed_row_count",
" , last_analyzed",
" , table_owner",
" , table_name",
" , foreign_key_name",
" , foreign_key_column_list",
" , index_name",
" , index_column_list",
" , create_index_ddl",
" from foreign_key_index_query",
"order by last_analyzed_row_count desc nulls last, table_owner, table_name, foreign_key_column_list;"
],
"description": "Find unindexed foreign keys"
}

Finding Unindexed Foreign Keys in Oracle

Update: There is a new better version of the query here: https://carsandcode.com/2025/08/13/finding-unindexed-foreign-keys-in-oracle-now-with-even-better-index-creation-ddl/

I’ve always been a bit unhappy with most (all?) of the queries you find when searching for “Oracle unindexed foreign keys query” on Google. Certainly, the ones that don’t use listagg to aggregate the constraint columns and the index columns are insane. I realize those queries might have been around since before listagg was available, but listagg was introduced in 11gR2 which was released in 2009.

To that end, the following 33 line query will find all the possibly unindexed foreign key constraints in your database. Since the matching happens on the column names, if you have a difference in column names between the constraint columns and the index columns, you won’t get a match and it will show up in the results as ‘Could be missing’. 99.99 times out of a hundred the index is missing.

I’ve also added the number of rows and last analyzed date from DBA_TABLES (remember, this number of rows may not be accurate as the value is only updated when the table is analyzed) so that the biggest possible offenders would be the first results. Even if the number of rows is small and the statistics are up to date, you’ll pretty much always want to have that foreign key index to eliminate over-locking the detail rows when a master row is updated. In all my years I’ve yet to run across a business application where the ‘index caused too much overhead’ and needed to be removed. I’m not saying those situations don’t exist, but they are exceedingly rare.

If you want to see how Oracle is doing with its schemas, you could change the owner_exclusion_list CTE (Common Table Expression, sometimes called a WITH clause) to something like “select ‘nobody’ from dual’). Right now that CTE gets the list of schemas that are Oracle maintained and adds in the ORDS_METADA and ORDS_PUBLIC_USER schemas which are, for some reason, not marked as Oracle maintained.

Using an APEX page to display the results of the query also allows me to format the last analyzed date using APEX’s “since” formatting, which gives a really good indication of possible stale statistics.

Update: Again, there is a new, better version of this query here: https://carsandcode.com/2025/08/13/finding-unindexed-foreign-keys-in-oracle-now-with-even-better-index-creation-ddl/

     with owner_exclusion_list as (          select username from dba_users where oracle_maintained ='Y'
                                   union all select 'ORDS_METADATA' from dual
                                   union all select 'ORDS_PUBLIC_USER' from dual )
        , constraint_columns as ( select owner
                                       , table_name
                                       , constraint_name
                                       , listagg(column_name, ', ') within group(order by position) as constraint_column_list
                                    from dba_cons_columns 
                                    join dba_constraints using (owner, table_name, constraint_name)
                                   where constraint_type = 'R' -- R = Referential Foreign Key Constraint
                                     and owner not in (select * from owner_exclusion_list)
                                group by owner, table_name, constraint_name )
        , index_columns as ( select index_owner as owner
                                  , table_name
                                  , index_name
                                  , listagg(column_name, ', ') within group(order by column_position) as index_column_list
                               from dba_ind_columns 
                              where index_owner not in (select * from owner_exclusion_list)
                           group by index_owner, table_name, index_name )
   select decode(ic.table_name, null, 'Could be missing'
                                    , 'Exists'          )              as foreign_key_index
        , to_char(dbat.num_rows, '999,999,999,999,999,999')            as last_analyzed_row_count
        , dbat.last_analyzed
        , cc.owner
        , cc.table_name
        , constraint_name                                              as foreign_key_constraint_name
        , constraint_column_list                                       as foreign_key_column_list
        , coalesce(index_name, '*** Possible Missing Index ***')       as index_name
        , coalesce(index_column_list,'*** Possible Missing Index ***') as index_column_list
     from constraint_columns cc
     join dba_tables dbat on ( dbat.owner = cc.owner and dbat.table_name = cc.table_name)
left join index_columns ic on (    cc.owner = ic.owner and cc.table_name = ic.table_name 
                               and ic.index_column_list like cc.constraint_column_list || '%' )
 order by dbat.num_rows desc nulls last, cc.owner, cc.table_name, constraint_column_list;

Update: And again, there is a new better version of this query here: https://carsandcode.com/2025/08/13/finding-unindexed-foreign-keys-in-oracle-now-with-even-better-index-creation-ddl/

Let me know if this query is helpful, and happy DBAing/Developing!

Note: On my Twitter post about this blog, I took a screenshot and put in the alt text for the image with the username column in the first CTE as ‘username’ instead of just username. This means that the Oracle maintained schemas are NOT excluded anymore. I was testing on a database that only had a single schema and wanted to include the Oracle schemas too. I also left off the owner column for the list in the final query.

2024-02 Update: As my friend Anton Nielsen pointed out, not everybody has DBA privs… If you don’t, just do a search and replace of “dba_” with “all_”. Then you can run this as a non-privileged user and you’ll be sure to capture missing indexes in your own schema and any other schema you can see.


Kscope23 Recap

I’ve been back for a while but thought I’d post some plans for my Kscope content and talk about the conference itself now that it’s in the bag.

I arrived late Friday night and things were pretty quiet. I spent some time in the bar trying various bourbons and whiskeys before calling it an early night.

The next morning I ran into Connor McDonald (check out his amazing content on Youtube). He mentioned that he was planning to go visit the famous Tom Kyte (the original creator of Ask TOM, and author of one of the very best Oracle books, Expert Oracle Database Architecture) but apparently Tom fell off a ladder and broke some ribs. Hopefully, Tom will recover soon. A bit later I joined the team going to the community service day event. They gave us matching shirts to wear and we boarded buses to Mile High Behavioral Healthcare and did a bunch of cleaning, gardening, and painting. I was on the painting crew and painted a gazebo and a few tables with a great team of folks. I ended up buying MHBH some picnic tables after we went to pick up one of their existing picnic tables and it essentially disintegrated. When one of the guests of MHBH asked me “How do I get one of those shirts?” I gave him mine. While the shirt was cool I’m sure he’ll get more use out of it than I would have.

By Sunday the rest of the Insum team had started to show up and we had a great time re-connecting.

Both of my main presentations were on Monday. Thankfully I’d delivered both before, so I was really comfortable with the content although it was going to be my first time delivering my “You know you might have a bad data model when…” presentation in just thirty minutes (I’d been given one of the 30 minute sessions for this presentation). It’s a bunch of slides with the type of queries that you would either see in your applications or queries you can run against your application schemas to see if there might be opportunities for enhancements. Upon the advice of Steven Feuerstein, another member of the Insum team, instead of starting with the theory of normalization, I started with the actual queries. Since the theory portion would take about 5 minutes, my plan was to cut it off at 25 minutes and jump to the theory. I set an alarm on my phone for 25 minutes, let the audience know what I planned to do, and dove in. When I finished my queries section, I glanced at my watch to see how much time I had left. As I was glancing at my watch, the alarm on my phone went off! It was perfect and I got a bit of applause from the audience. I finished off the theory portion and then got a lot of good feedback including from some of the very experienced folks in the audience (Oracle ACE directors, etc.).

Later in the day, I did my “APEX and ORDS for DBAs and System Admins” presentation. While I’ve delivered the content at other events before, I always update my presentations to the latest and greatest software, and with the very frequent updates of both ORDS and APEX I had to update everything a few days before the conference.

This presentation is actually about 2 to 4 hours of content, but I only had an hour to deliver it. Basically, I cut the presentation in half and gave folks a preview of what would be in the 2nd half if I had more time. I also went through the first half of the presentation pretty quickly. The premise of the presentation is that people often come to Kscope and they see all these really cool APEX solutions, but then when they go back to their own IT department the DBA or System Admin just says “No.” The reason for the “No” can be anything they feel like coming up with at the time (“Security”, “Too complicated”, “I don’t know how to do it”, etc.) but the conference attendee doesn’t get the very cool APEX and/or ORDS features that they saw at the conference. To solve this problem, I broke the first half of the presentation into three sections.

  1. Install the APEX database component and patch it in the database.
    This section shows that APEX is just a component of the database (it shows up in the dba_registry view) and, by showing what is happening in the database we see that there are NO security changes in the database when you do this. It also showed how the installation and patch take under 10 minutes. On my boxes, the APEX install takes usually takes under six minutes and the patch takes under 30 seconds.
  2. Now that you have the APEX component in the database, you have access to bunches of very cool APIs. Some of those APIs enable you to reach out of the database to access things on remote servers if the DBAs allow it. I show exactly how to enable this and how to create and manage an Oracle TLS wallet (yes, people and Oracle often refer to this as an SSL wallet, but we really stopped using SSL back in the 1990s. It’s been TLS for over 20 years… and it really is a TLS wallet). Wallet management can be very tricky and I can’t tell you the number of times I’ve seen very bad wallet setups on Oracle servers. I explain the various wallet types (TDE wallet, SEPS wallet, XDB wallet, and TLS wallet) and show how I build and maintain them.
  3. Finally, we get to the last step which is setting up and configuring ORDS for a production-ready deployment. While Kris Rice, the Oracle lead for the ORDS team, disagrees with me, I really don’t like deploying ORDS with /ords/ in the URL. As Sir Tim Berner’s Lee explains, cool URLs don’t have the technology used to deploy them in the URL. Yes, that link is from 1998. I figure when Kris is knighted by the King he can tell me I’m wrong. I also show how to maintain ORDS over time. I show how to create the best

At this point, I’ve covered the first half of the presentation but an hour has passed and I have to just show folks what the next three sections are:

  • Adding a database resource manager plan that ‘just works’ for APEX deployments.
  • Building a Systemd service to run ORDS.
  • Deploying the ORDS service as a ‘regular’ operating system user instead of the root user. The root user can, of course, deploy ORDS on port 443 (the default HTTPS port) trivially, but some folks would prefer not to run ORDS as root, so I show how to do this.

My plan is to take all of that content and publish it here (update: The entire 2-hour APEX & ORDS for DBAs and System Admins is now live!), but it will take a while. Life is very busy and I’m not exactly sure when I’ll be able to finish it all. Until then, all of the code and examples from my both presentations can be found in the following Github repos:

Finally, I was a co-presenter with Cary Milsap of Method R in a presentation on tracing APEX. It was also well-received and sparked a lot of interesting discussions.

When I do get the time to post everything from my presentations I’ll try to remember to come back and here and link it in. Update: Here’s the link to the whole presentation from start to finish!

Until then, happy DBAing, System Admining, APEXing, and ORDSing.