Home Database ORA-00904 Invalid Identifier — Typo and Case Fix
Beginner 5 min · September 23, 2026

ORA-00904 Invalid Identifier — Typo and Case Fix

Fix Oracle ORA-00904 by correcting the column typo, matching quoted case, and scoping table aliases.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.

Follow
Production
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
Before you start⏱ 10 min
  • An Oracle database (11g+) with dictionary access
  • The failing SQL text plus the schema it runs against
  • Basic comfort reading all_tab_columns output
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • ORA-00904 means Oracle parsed your SQL and couldn't match a name to any column, alias, or function — it's a naming failure at parse time, never a data problem
  • Check spelling against the dictionary first: SELECT column_name FROM all_tab_columns WHERE table_name = 'ORDERS' shows what actually exists
  • Quoted identifiers are case-sensitive: a column created as "OrderStatus" is invisible to orderstatus, ORDERSTATUS, or any other casing variant
  • A WHERE clause can't use a SELECT alias from the same level, and a forgotten table alias prefix sends Oracle hunting for a column that isn't there
✦ Definition~90s read
What is ORA-00904 Invalid Identifier Fix?

ORA-00904 fires during parsing when an identifier matches nothing Oracle knows: no column of any table in scope, no table alias, no function, no pseudocolumn. The quoted name in the message is exactly what the parser tried and failed to resolve — including its case.

Picture a teacher taking attendance from the official roster.

Because it fails before execution, it never involves data, locks, or privileges; it's the compiler telling you the program references something undefined.

Five distinct causes share the code. A plain typo or a stale reference after a rename is the most common: the query says orderstatus, the table now has status. Quoted-case mismatch is next: CREATE TABLE t ("OrderStatus" ...) stores mixed case, and every unquoted reference folds to ORDERSTATUS, which doesn't match.

Alias-scope errors follow: SELECT amount * 1.2 AS total ... WHERE total > 100 fails because WHERE is evaluated before SELECT aliases exist at the same level. Missing table scope (forgetting the alias prefix in a join, or omitting the table from FROM entirely) makes an existing column unresolvable.

Finally, reserved words — COMMENT, NUMBER, DATE, UID, SIZE and friends — can't appear as bare identifiers.

The fix always starts at the dictionary: all_tab_columns (or user_tab_columns) for columns, all_tables for tables, V$RESERVED_WORDS for keyword collisions. One catalog query classifies all five causes, which is why guessing from the message alone wastes time.

Plain-English First

Picture a teacher taking attendance from the official roster. ORA-00904 is calling out a name that isn't on it — maybe you misspelled it (typo), maybe the student goes by a nickname but the roster has the legal name (quoted case mismatch), maybe you're reading from last year's roster (renamed column), or maybe you're calling a name into the wrong classroom (missing alias scope). The fix is always the same motion: look at the actual roster (the data dictionary), then say the name written there.

ORA-00904: "ORDERSTATUS": invalid identifier. It appears the moment you run the statement — no partial results, no rows, just a parse-time rejection. Usually it follows a deploy (a renamed column), a handoff (someone's query against your schema), or a late night (a typo in the third join). The maddening part is that the name looks right until you compare it against the dictionary character by character.

Oracle validates every identifier against the data dictionary before executing anything, and its rules surprise newcomers: unquoted names fold to UPPERCASE, quoted names preserve exact case, SELECT aliases are invisible to WHERE at the same level, and a few dozen reserved words can't serve as bare columns at all.

This guide walks the resolution order: dictionary check, typo versus rename triage, quoted-case matching, alias-scope repair, and reserved-word handling — plus a CI gate that parses report SQL against the dictionary before it ships. Keep this guide bookmarked: the resolution order below compresses most 00904 incidents into a ten-minute dictionary exercise.

ORA-00904 Fails at Parse Time

Internalize this and half the debugging folklore dies: 00904 happens before Oracle reads a single row. No data change, no concurrent session, no grant can cause or cure it — the parser walks your identifiers, consults the dictionary, and rejects the unknown one. That is why retrying never helps, why it reproduces perfectly in SQL Developer, and why the fix is always in the text of the SQL or the state of the schema, never in the server.

The message quotes the failed identifier exactly, case included — "ORDERSTATUS" versus "OrderStatus" is itself a clue about quoting. Copy it verbatim into your dictionary queries rather than retyping from memory; retyping introduces the same class of error you're hunting. And test with fragments: SELECT "OrderStatus" FROM orders WHERE ROWNUM = 1 parses (or fails) in a second, while the 200-line report takes minutes to even read.

Because it's parse-time, EXPLAIN PLAN is a perfect validator: it parses without executing, so EXPLAIN PLAN FOR <query> in CI fails builds on exactly the identifier production would reject. Parse-testing is cheap, deterministic, and complete — there is no 00904 that passes EXPLAIN PLAN and fails at runtime. Make it the default validator.

dict_check_904.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
-- What columns actually exist? (compare character by character)
SELECT column_name, data_type, nullable
FROM all_tab_columns
WHERE table_name = 'ORDERS'
ORDER BY column_id;

-- Table itself present?
SELECT table_name FROM all_tables WHERE table_name = 'ORDERS';

-- Minimal parse test (ROWNUM keeps it instant)
SELECT status FROM orders WHERE ROWNUM = 1;
📊 Production Insight
A team audited grants for 90 minutes before anyone queried all_tab_columns — which showed the rename in ten seconds. Parse-time errors demand dictionary-first debugging.
🎯 Key Takeaway
00904 is the parser rejecting a name: copy the quoted identifier verbatim into dictionary queries and test with fragments.

Typo'd or Renamed Columns After Deploys

The most common story is also the simplest: the query and the schema disagree. Fresh typos (custmoer_id), stale references after RENAME COLUMN, and cross-branch drift (your branch renamed it, main didn't) all produce byte-identical symptoms. Triage by diffing the query's column list against all_tab_columns output: every name must match exactly, and the first mismatch is usually the reported one — Oracle names the first failure, not all of them.

Renames deserve a process, not just a fix. ALTER TABLE ... RENAME COLUMN is instant and safe, but every consumer — app code, reports, ETL, materialized view definitions — must move in the same release or behind a compatibility alias. Search the codebase for the old name (including quoted variants and dynamic SQL strings) before the rename ships; dynamic SQL built by concatenation hides from every static check except a runtime parse test.

For the immediate incident, prefer the compatibility alias over the revert: a view or synonym carrying the old name restores service in minutes without re-migrating the app schema. Reverts re-break the new code that already adopted the rename; aliases let both generations coexist until the slow train catches up.

rename_triage.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
-- Diff the query's names against reality
SELECT column_name FROM all_tab_columns
WHERE table_name = 'ORDERS';

-- What did the deploy actually rename? (review the migration)
-- ALTER TABLE orders RENAME COLUMN order_status TO status;

-- Compatibility alias: old name keeps working for slow consumers
CREATE OR REPLACE VIEW orders_compat AS
SELECT order_id, customer_id, status AS order_status, total
FROM orders;
-- Point legacy reports at orders_compat until they upgrade.
📊 Production Insight
A Friday rename broke 12 report queries across a separately-versioned package. A compatibility view restored service in 10 minutes; the revert would have re-broken the app.
🎯 Key Takeaway
Diff query names against the dictionary, restore service with a compatibility alias, and move all consumers in one release.

Quoted Case: "Name" Is Not NAME

Oracle folds unquoted identifiers to UPPERCASE — status, Status, and STATUS all mean STATUS. Double quotes opt out: "OrderStatus" stores mixed case permanently, and from then on only the exact quoted form resolves. Every unquoted reference folds to ORDERSTATUS, misses, and raises 00904 against a column that plainly appears in DESCRIBE output. Tools that auto-quote (some ORMs, GUI designers) manufacture this trap silently.

Detect it with a case-mismatch query: any column_name differing from its UPPER form was created quoted. Prove it with the minimal pair — unquoted fails, quoted succeeds — before changing anything, because the same symptom (exists-but-unresolvable) also comes from scope errors, and the quoted probe distinguishes them cleanly.

Cure it with a rename to unquoted convention, not with permanent quoting. Quoting every reference forever taxes developers, breaks case-insensitive tooling assumptions, and corrupts dump-and-reload scripts that normalize case. One RENAME COLUMN to STATUS plus a code sweep ends the tax; quote-forever pays it daily. Sweep dynamically built SQL too — concatenated column names hide quoted references from every static search. Grep for the column in all casings to be thorough.

quoted_case_fix.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
-- Which columns were created quoted? (differ from UPPER)
SELECT column_name FROM all_tab_columns
WHERE table_name = 'ORDERS'
  AND column_name <> UPPER(column_name);

-- Prove the trap: unquoted folds and misses...
-- SELECT OrderStatus FROM orders WHERE ROWNUM = 1;  -- ORA-00904
-- ...quoted exact-case hits
SELECT "OrderStatus" FROM orders WHERE ROWNUM = 1;

-- Cure: rename to unquoted convention once
ALTER TABLE orders RENAME COLUMN "OrderStatus" TO status;
⚠ Don't Quote Forever — Rename Once
Quoting every reference to a mixed-case column works but taxes all future code, tooling, and migrations. Rename to unquoted UPPERCASE convention once and sweep the references.
📊 Production Insight
A GUI-designed table with quoted mixed-case columns 00904'd every hand-written query for months. One rename migration ended an entire category of tickets.
🎯 Key Takeaway
Unquoted folds to UPPER; quoted preserves exact case — detect with UPPER comparison, prove with the minimal pair, cure with rename.

Alias Scope: WHERE Can't See SELECT Aliases

SQL evaluates WHERE before SELECT at the same query level, so SELECT amount * 1.2 AS total ... WHERE total > 100 references something that doesn't exist yet — 00904 on a perfectly spelled name. The same blindness covers GROUP BY and HAVING at the same level; ORDER BY, evaluated later, sees aliases fine. This asymmetry confuses everyone exactly once, then becomes muscle memory.

Three repairs, pick by clarity. Repeating the expression in WHERE is simplest for short formulas. Nesting — inner query defines the alias, outer query filters — reads best for complex expressions and works in every clause. A WITH subquery (factored) names the computation once and references it everywhere, which scales when five downstream clauses need the same value.

The sibling scope bug is the wrong table prefix: SELECT o.status FROM orders ord fails because the alias is ord. Alias consistently (short, stable prefixes per table), qualify every column in joins, and the parser stops complaining about names that 'exist' — because now they're addressable. When a join still fails after prefixes look right, comment out tables one by one: the last table removed before parsing succeeds is the scope you broke. Label that join explicitly.

alias_scope_fix.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
-- Fails: WHERE can't see the same-level alias
-- SELECT amount * 1.2 AS total FROM orders WHERE total > 100;

-- Fix 1: repeat the expression
SELECT amount * 1.2 AS total FROM orders WHERE amount * 1.2 > 100;

-- Fix 2: nest (inner defines, outer filters)
SELECT * FROM (SELECT amount * 1.2 AS total FROM orders)
WHERE total > 100;

-- Fix 3: factor with WITH
WITH priced AS (SELECT amount * 1.2 AS total FROM orders)
SELECT * FROM priced WHERE total > 100;
📊 Production Insight
A 00904 on a 'correct' alias wasted an hour before someone remembered evaluation order. Nesting the query fixed it in one edit — no schema change involved.
🎯 Key Takeaway
WHERE/GROUP BY run before SELECT aliases exist — repeat, nest, or factor the expression instead.

Reserved Words as Column Names

COMMENT, DATE, NUMBER, SIZE, UID, GROUP, ORDER — Oracle reserves dozens of ordinary-looking words, and a bare reference parses as the keyword, then fails as an identifier (or worse, parses as something unintended). Legacy schemas inherit these from looser eras: a column named comment from 2009 works until the first query that uses it unquoted in a new context.

Check V$RESERVED_WORDS before assuming a typo: one row proves the name was never going to work bare. The stopgap is quoting ("COMMENT") wherever it appears; the cure is renaming to a non-reserved equivalent (customer_comment, order_note) on the next maintenance window. Quoted reserved words technically function but poison every ORM mapping, export script, and new-hire onboarding that touches the table.

Prevent recurrence with a reserved-word lint on DDL: any CREATE TABLE or ALTER ... ADD containing a V$RESERVED_WORDS match fails review. The dictionary already knows the full list — point the linter at it instead of maintaining a stale copy in a wiki page nobody reads. Include ALTER TABLE ... ADD variants in the lint, since most reserved-word columns sneak in through later alters rather than the original CREATE TABLE statement.

reserved_word_check.sqlSQL
1
2
3
4
5
6
7
8
9
-- Is the name reserved? (one row = never works bare)
SELECT keyword, reserved FROM V$RESERVED_WORDS
WHERE keyword = 'COMMENT';

-- Stopgap: quote it everywhere (works, but taxes all future SQL)
SELECT "COMMENT" FROM tickets WHERE ROWNUM = 1;

-- Cure on the next window: rename to a safe equivalent
ALTER TABLE tickets RENAME COLUMN "COMMENT" TO customer_comment;
📊 Production Insight
A legacy comment column 00904'd every new report until someone checked V$RESERVED_WORDS. The rename took one window; the quoting workarounds had cost a year.
🎯 Key Takeaway
Check V$RESERVED_WORDS for innocent-looking names — quote as stopgap, rename as cure, lint DDL going forward.

Prevention: Dictionary-Driven CI

End the class, not the instance. A CI job that runs EXPLAIN PLAN FOR over every report query and migrationAdjacent SELECT fails the build on the exact identifier production would reject — with the rename's own pull request as the culprit, not the 6 AM batch. Keep a shadow schema at the target migration level so the parse test runs against tomorrow's dictionary, not today's.

Add two linters beside it: one rejecting newly quoted mixed-case identifiers in DDL, one rejecting V$RESERVED_WORDS matches as bare columns. Both read from the live dictionary, so they never go stale. Together the three gates (parse test, case lint, keyword lint) cover every 00904 cause except the alias-scope rule — which code review catches because the failing pattern (alias in WHERE) is visually distinctive.

Version your reporting SQL with the schema it targets. A reports package declaring compatibility with migration N gets parse-tested against N; a rename to N+1 without a compatibility alias fails that package's gate loudly. Contracts beat coordination meetings: the schema and its consumers stay in sync because the pipeline enforces it, not because everyone remembers to check every consumer by hand before merging.

parse_gate_904.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
# CI gate: every report query must parse against the target schema
set -euo pipefail
CONNECT="app/pass@//db-ci:1521/shop"
FAIL=0
for Q in reports/*.sql; do
  if ! sqlplus -S "$CONNECT" << EOF > /tmp/parse.log 2>&1
WHENEVER SQLERROR EXIT FAILURE
EXPLAIN PLAN FOR $(cat "$Q");
ROLLBACK;
EOF
  then echo "PARSE-FAIL $Q"; grep -i 'ORA-' /tmp/parse.log | head -3; FAIL=1;
  else echo "PARSE-OK $Q";
  fi
done
[ "$FAIL" -eq 0 ] && echo GATE-PASS || (echo GATE-FAIL; exit 1)
💡EXPLAIN PLAN Is a Free Parse Test
EXPLAIN PLAN parses without executing, so it validates every identifier for free. Run it over all report SQL in CI against a shadow schema at the target migration level.
📊 Production Insight
A parse gate against the shadow schema now fails renames that break reports — the exact Friday incident, caught on Friday afternoon instead of Monday 6 AM.
🎯 Key Takeaway
Parse-test report SQL in CI, lint case and keywords from the live dictionary, and version consumer contracts per migration.
● Production incidentPOST-MORTEMseverity: high

A Renamed Column Broke Every Morning Report for 3 Hours

Symptom
At 6:00 AM all 46 scheduled reports failed with ORA-00904: "ORDERSTATUS": invalid identifier — 214 failed executions by 7 AM, and the exec dashboard stayed blank through the 8 AM leadership review. The application itself was green: only the reporting package used the old name. Support re-ran the jobs hourly, generating 600+ identical failures that buried the one alert that mattered.
Assumption
The reporting team assumed a permissions revocation from the weekend's security rollout and requested re-grants twice. The DBA team obliged, changing nothing, because a parse-time resolution failure never reaches privilege checks. Ninety minutes went to auditing grant scripts that were never in the path.
Root cause
Friday's deploy ran ALTER TABLE orders RENAME COLUMN order_status TO status; but the reporting package — versioned separately and deployed monthly — still selected orderstatus in 12 queries. Any dictionary check (SELECT column_name FROM all_tab_columns WHERE table_name = 'ORDERS') would have shown status in seconds. The two release trains had no shared contract test, so the rename shipped blind to its biggest consumer.
Fix
At 9:00 AM they added a compatibility view mapping the old name (CREATE VIEW orders_compat AS SELECT ..., status AS order_status FROM orders) and pointed the 12 queries at it — reports recovered in 10 minutes without touching the app schema. The view was deprecated the same sprint as the package upgrade caught up. Follow-ups: a CI job that parses every report query against the dictionary, and a rule that column renames ship a compatibility alias for one release.
Key lesson
  • Parse report SQL against the dictionary in CI: a rename that breaks 12 queries should fail the deploy, not the 6 AM batch.
  • Ship renames with compatibility aliases: a view or synonym carrying the old name for one release decouples the app and reporting trains.
  • Check the dictionary before grants: ORA-00904 fails at parse time, so privilege changes can never fix it — one catalog query beats ninety minutes of grant audits.
Production debug guideSix checks from the dictionary outward — naming first, never grants.6 entries
Symptom · 01
Statement fails with ORA-00904: "NAME": invalid identifier
Fix
Ask the dictionary what exists: SELECT column_name, data_type FROM all_tab_columns WHERE table_name = 'ORDERS' ORDER BY column_id; Compare the reported name character by character — including case. If the column is absent, it's a typo or a rename (check the deploy's ALTER statements). If present with different case, it's a quoting issue — keep going.
Symptom · 02
Column exists but the query still raises 00904
Fix
Check for quoted mixed case: SELECT column_name FROM all_tab_columns WHERE table_name = 'ORDERS' AND column_name <> UPPER(column_name); Any row returned was created quoted and demands exact-case quoted references everywhere. Prove it minimally: SELECT "OrderStatus" FROM orders WHERE ROWNUM = 1; then plan the rename to lowercase-or-upper unquoted.
Symptom · 03
00904 names a SELECT alias used in WHERE, GROUP BY, or HAVING
Fix
That's the scope rule, not a typo: same-level WHERE can't see SELECT aliases. Rewrite by repeating the expression (WHERE amount 1.2 > 100), nesting (SELECT FROM (SELECT amount * 1.2 AS total FROM orders) WHERE total > 100), or moving the filter to an outer query. The alias works in ORDER BY at the same level — only WHERE/GROUP BY/HAVING are blind to it.
Symptom · 04
00904 on a column you can see in the table, inside a join
Fix
Check the table scope: every referenced table needs to be in FROM (with the right alias), and the prefix must match the alias exactly — SELECT o.status FROM orders ord fails because the alias is ord, not o. Strip the query to one table first; if the single-table form parses, re-add joins one at a time until the broken prefix appears.
Symptom · 05
00904 on a short ordinary-looking word like COMMENT, SIZE, or UID
Fix
Test for reserved words: SELECT keyword FROM V$RESERVED_WORDS WHERE keyword = 'COMMENT'; If it returns a row, quote the identifier ("COMMENT") as a stopgap and rename the column on the next maintenance window — quoted reserved words work but tax every future query and tool.
Symptom · 06
You need to prove the fix without running the full report
Fix
Validate minimally: wrap the suspect fragment as SELECT <expr> FROM <table> WHERE ROWNUM = 1; — parsing is what you're testing, not results. For fleet safety, run EXPLAIN PLAN FOR <report query>; in CI: parse failures fail the build with the exact identifier, long before the 6 AM batch discovers them.
ORA-00904 Causes at a Glance
Root CauseHow to ConfirmFixPrevention
Typo or stale post-rename referenceColumn absent from all_tab_columnsCorrect spelling; compatibility view for renamesParse-test report SQL in CI
Quoted mixed-case columncolumn_name differs from UPPER formQuote exactly now; RENAME to conventionLint DDL against quoted identifiers
SELECT alias used in WHERE/GROUP BYName exists only as same-level aliasRepeat, nest, or factor the expressionReview for alias-in-WHERE pattern
Wrong/missing table alias in joinSingle-table form parses; join failsFix prefix to declared alias; qualify allConsistent short aliases per table
Reserved word as bare columnV$RESERVED_WORDS returns the wordQuote as stopgap; rename as cureKeyword lint on all DDL
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
dict_check_904.sqlSELECT column_name, data_type, nullableORA-00904 Fails at Parse Time
rename_triage.sqlSELECT column_name FROM all_tab_columnsTypo'd or Renamed Columns After Deploys
quoted_case_fix.sqlSELECT column_name FROM all_tab_columnsQuoted Case
alias_scope_fix.sqlSELECT amount * 1.2 AS total FROM orders WHERE amount * 1.2 > 100;Alias Scope
reserved_word_check.sqlSELECT keyword, reserved FROM V$RESERVED_WORDSReserved Words as Column Names
parse_gate_904.shset -euo pipefailPrevention

Key takeaways

1
00904 fails at parse time
debug names via the dictionary, never via grants or retries.
2
Diff query identifiers against all_tab_columns character by character, case included.
3
Quoted identifiers preserve case
detect, prove with the minimal pair, rename to convention.
4
Same-level WHERE can't see SELECT aliases
repeat, nest, or factor.
5
Check V$RESERVED_WORDS for innocent-looking names; quote as stopgap, rename as cure.
6
Parse-test all report SQL in CI against a shadow schema at the target migration.

Common mistakes to avoid

5 patterns
×

Auditing grants for a parse-time error

Symptom
Re-grants change nothing; 00904 reproduces for superusers too.
Fix
Check the dictionary first — resolution fails before privilege checks ever run.
×

Retrying the statement hoping it resolves

Symptom
Identical failure forever; retries prove determinism, not progress.
Fix
Reproduce minimally with ROWNUM = 1 and fix the name or the schema.
×

Quoting mixed-case columns forever

Symptom
Works, but every future query, ORM map, and export must quote exactly.
Fix
Rename to unquoted convention once and sweep references.
×

Reverting a rename instead of aliasing

Symptom
Reports recover; the app adopting the new name breaks instead.
Fix
Ship a compatibility view/synonym for one release; let both generations coexist.
×

Building dynamic SQL by concatenating column names

Symptom
Static checks pass; runtime 00904s from assembled strings nobody reviewed.
Fix
Validate dynamic names against all_tab_columns at runtime or parse-test generated SQL.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does ORA-00904 mean?
Q02SENIOR
A column exists but queries raise 00904. What do you check?
Q03SENIOR
Why does SELECT * FROM OrderItems fail when the table was created as "Or...
Q04SENIOR
Why can't WHERE use a SELECT alias at the same level?
Q05SENIOR
How do you stop renames from breaking separately-versioned consumers?
Q01 of 05JUNIOR

What does ORA-00904 mean?

ANSWER
Invalid identifier: the parser couldn't resolve a name to any column, alias, or function. It's a parse-time naming failure — never data, locks, or grants.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is ORA-00904 a permissions problem?
02
Why does the same query work for my teammate?
03
Can I use a SELECT alias in GROUP BY?
04
How do I find all references to a renamed column?
05
Are double-quoted identifiers ever the right choice?
06
What's the fastest single validation?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.

Follow
Verified
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
🔥

That's Oracle. Mark it forged?

5 min read · try the examples if you haven't

Previous
Postgres Too Many Clients Fix
1 / 5 · Oracle
Next
ORA-01722 Invalid Number Fix