External Tables
An external table looks like a regular Oracle table but its data lives in files on the operating system, not inside the database. You can SELECT * FROM ext_csv_data and get back rows parsed from a CSV file on disk โ without ever loading the data into the database.
External tables are Oracle's clean alternative to running SQL*Loader, file scraping scripts, or external ETL tools for many common load and read workloads.
What External Tables Are Good For
1. Loading data from CSV / flat files
The classic ETL pattern: dump from one system into a flat file, copy the file to an Oracle directory, define an external table over it, then INSERT INTO target SELECT * FROM ext_table. Simpler than SQL*Loader, no separate utility to run.
2. Querying log files without importing
Tail a CSV produced by some other process and run ad-hoc SQL against it. The file stays on disk; no storage cost inside the database.
3. Sharing data between databases
Database A exports a file using ORACLE_DATAPUMP, copies it to a directory accessible by database B, which then reads it via an external table. No DB link required.
4. Backing data into the database lazily
External tables can be the source of a materialised view that refreshes on demand โ incremental data loads without the SQL*Loader machinery.
The Two Access Drivers
Oracle ships two drivers for external tables:
| Driver | Reads/Writes | Use case |
|---|---|---|
ORACLE_LOADER |
Reads CSV, fixed-width, delimited text | Plain text files |
ORACLE_DATAPUMP |
Reads and writes Oracle's binary format | Database-to-database transfer |
ORACLE_LOADER is read-only (no INSERT into the external table). ORACLE_DATAPUMP can write โ useful for exporting query results into a file Oracle can re-read elsewhere.
Directory Objects
External tables can only read files inside a directory object โ a named alias for an OS path. Directories are how Oracle controls which OS paths the database may touch.
-- DBA creates the directory:
CREATE OR REPLACE DIRECTORY ext_data_dir AS '/u01/data/external';
-- Grant read access to a specific user:
GRANT READ ON DIRECTORY ext_data_dir TO hr_user;
-- For writing (Datapump or BAD/LOG files):
GRANT WRITE ON DIRECTORY ext_data_dir TO hr_user;
A directory is just a name pointing at a path. It does not create the OS folder โ the folder must already exist with appropriate permissions for the Oracle OS user.
Inspect directories:
SELECT directory_name, directory_path FROM all_directories;
Defining an External Table with ORACLE_LOADER
The minimal form for a CSV file:
CREATE TABLE ext_employees (
employee_id NUMBER,
first_name VARCHAR2(50),
last_name VARCHAR2(50),
email VARCHAR2(100),
hire_date DATE,
salary NUMBER
)
ORGANIZATION EXTERNAL (
TYPE ORACLE_LOADER
DEFAULT DIRECTORY ext_data_dir
ACCESS PARAMETERS (
RECORDS DELIMITED BY NEWLINE
SKIP 1 -- skip header row
FIELDS TERMINATED BY ','
OPTIONALLY ENCLOSED BY '"'
MISSING FIELD VALUES ARE NULL
(
employee_id,
first_name,
last_name,
email,
hire_date DATE 'YYYY-MM-DD',
salary
)
)
LOCATION ('employees.csv')
)
REJECT LIMIT UNLIMITED;
Now SELECT * FROM ext_employees parses employees.csv from /u01/data/external and returns rows.
Key Access Parameters
| Parameter | Purpose |
|---|---|
RECORDS DELIMITED BY NEWLINE |
One record per line (default) |
RECORDS FIXED n |
Each record is exactly n bytes |
FIELDS TERMINATED BY ',' |
Delimiter between fields |
OPTIONALLY ENCLOSED BY '"' |
Strip surrounding quotes if present |
MISSING FIELD VALUES ARE NULL |
Treat missing fields as NULL instead of error |
SKIP n |
Skip the first n lines (headers) |
LOAD WHEN field = value |
Load only matching rows |
BADFILE / LOGFILE / DISCARDFILE |
Where to write rejected rows and logs |
REJECT LIMIT n / UNLIMITED |
How many bad rows to tolerate |
Field Type Conversions
The column types defined in the CREATE TABLE come from Oracle's type system; the source file is always text. Specify conversion patterns when needed:
(
order_id INTEGER EXTERNAL,
order_date DATE 'YYYY-MM-DD HH24:MI:SS',
amount DECIMAL EXTERNAL,
description CHAR(200)
)
INTEGER EXTERNAL and DECIMAL EXTERNAL mean "the file has the value as a text string; convert to numeric". Without EXTERNAL, ORACLE_LOADER expects the value in binary format.
Handling Bad Records
By default, parsing errors abort the query. Three files capture rejected data:
| File | Purpose |
|---|---|
BADFILE |
Records that failed to parse |
LOGFILE |
Summary of the load attempt and errors encountered |
DISCARDFILE |
Records rejected by LOAD WHEN condition |
CREATE TABLE ext_employees (
...
)
ORGANIZATION EXTERNAL (
TYPE ORACLE_LOADER
DEFAULT DIRECTORY ext_data_dir
ACCESS PARAMETERS (
RECORDS DELIMITED BY NEWLINE
BADFILE ext_data_dir : 'employees.bad'
LOGFILE ext_data_dir : 'employees.log'
DISCARDFILE ext_data_dir : 'employees.dis'
FIELDS TERMINATED BY ','
( ... )
)
LOCATION ('employees.csv')
)
REJECT LIMIT 100; -- tolerate up to 100 bad rows
Per row that fails: the parsed input goes to BADFILE, an error message to LOGFILE. Inspect these after every load.
Loading from External Table into a Real Table
The canonical ETL pattern:
-- Once: define the external table over the file format
CREATE TABLE ext_employees (...);
-- For each load:
INSERT INTO employees (employee_id, first_name, last_name, email, hire_date, salary)
SELECT employee_id, first_name, last_name, email, hire_date, salary
FROM ext_employees;
COMMIT;
For large loads, use the APPEND hint to bypass the buffer cache and direct-path insert:
INSERT /*+ APPEND */ INTO employees SELECT * FROM ext_employees;
This is significantly faster but the rows aren't visible until COMMIT and the table is briefly locked.
Multiple Files in One External Table
You can union multiple files into one table:
ORGANIZATION EXTERNAL (
...
LOCATION ('part1.csv', 'part2.csv', 'part3.csv')
)
The external table presents all three files as a single rowset. Useful when your source system splits a daily dump into multiple files.
You can also point to different directories:
LOCATION (
data_dir_1 : 'jan.csv',
data_dir_2 : 'feb.csv'
)
ORACLE_DATAPUMP โ Binary Read/Write
ORACLE_DATAPUMP is the binary equivalent. It can both read existing Datapump files and write a Datapump file from a SELECT statement.
Write a query result to a Datapump file:
CREATE TABLE emp_export
ORGANIZATION EXTERNAL (
TYPE ORACLE_DATAPUMP
DEFAULT DIRECTORY ext_data_dir
LOCATION ('emp_export.dmp')
)
AS
SELECT * FROM employees WHERE department_id = 50;
This creates a file emp_export.dmp containing the selected rows in Datapump's binary format.
Read a Datapump file on another database:
-- On the destination database:
CREATE TABLE emp_import (
employee_id NUMBER,
first_name VARCHAR2(50),
...
)
ORGANIZATION EXTERNAL (
TYPE ORACLE_DATAPUMP
DEFAULT DIRECTORY ext_data_dir
LOCATION ('emp_export.dmp')
);
-- Query it:
SELECT * FROM emp_import;
Compared to ORACLE_LOADER, Datapump:
- Reads/writes binary โ much faster, smaller files
- Both directions supported
- Less flexible for ad-hoc CSV ingestion
External Tables vs SQL*Loader
Both exist for loading flat files. The trade-offs:
| Aspect | External Tables | SQL*Loader |
|---|---|---|
| Setup | DDL inside SQL | Separate utility |
| Querying | Full SQL on the data | None (write-only) |
| Joins, filters during load | Use SELECT to filter | Limited WHEN clauses |
| Parallel direct-path | Yes (parallel insert) | Yes |
| Conditional loading | Use SELECT ... WHERE | LOAD WHEN clauses |
| Multiple targets | Multiple INSERTs from one SELECT | Multiple INTO TABLE blocks |
| Operating environment | DBA-controlled directories | Client-side or server-side |
| Pre-12c | Available since 9i | Mature, decades old |
Default to external tables in modern Oracle. SQL*Loader retains a niche when you need to load from a network-connected client without granting filesystem access on the server. External tables are easier to use, integrate with SQL, and are the recommended approach for new code.
Limitations
External tables can't do everything a regular table can:
- Read-only with ORACLE_LOADER โ you can't INSERT, UPDATE, or DELETE through it
- No indexes โ every query is a full scan of the underlying file
- No constraints โ except NOT NULL on columns
- Slower for repeated access โ every query re-reads and re-parses the file
- No partitioning (though you can have multiple files as separate locations)
- File must be on the database server's filesystem โ not a remote URL
For frequently-queried data, load it into a real table once and query the table. External tables are best for one-shot loads and infrequent queries.
Inspecting External Tables
-- Confirm a table is external and see its driver:
SELECT table_name, type_name, default_directory_name
FROM user_external_tables;
-- See all access parameters:
SELECT * FROM user_external_tables WHERE table_name = 'EXT_EMPLOYEES';
-- See the file locations:
SELECT * FROM user_external_locations WHERE table_name = 'EXT_EMPLOYEES';
Worked Example โ Loading a Daily CSV with Validation
Requirements:
- A vendor drops a daily CSV
orders_YYYYMMDD.csvinto/u01/incoming - Bad rows must be logged but not fail the load
- Only rows with
status = 'CONFIRMED'should be loaded - After load, the file should be archived
Setup (one-time, DBA):
CREATE OR REPLACE DIRECTORY incoming AS '/u01/incoming';
CREATE OR REPLACE DIRECTORY archive AS '/u01/incoming/archive';
GRANT READ, WRITE ON DIRECTORY incoming TO etl_user;
GRANT WRITE ON DIRECTORY archive TO etl_user;
External table definition:
CREATE TABLE ext_orders (
order_id NUMBER,
customer_id NUMBER,
order_date DATE,
status VARCHAR2(20),
total NUMBER
)
ORGANIZATION EXTERNAL (
TYPE ORACLE_LOADER
DEFAULT DIRECTORY incoming
ACCESS PARAMETERS (
RECORDS DELIMITED BY NEWLINE
BADFILE incoming : 'orders.bad'
LOGFILE incoming : 'orders.log'
SKIP 1
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
MISSING FIELD VALUES ARE NULL
(
order_id INTEGER EXTERNAL,
customer_id INTEGER EXTERNAL,
order_date DATE 'YYYY-MM-DD',
status CHAR(20),
total DECIMAL EXTERNAL
)
)
LOCATION ('orders_current.csv')
)
REJECT LIMIT 100;
Daily load procedure:
BEGIN
-- Insert only confirmed orders into the real table
INSERT /*+ APPEND */ INTO orders (order_id, customer_id, order_date, status, total)
SELECT order_id, customer_id, order_date, status, total
FROM ext_orders
WHERE status = 'CONFIRMED';
COMMIT;
-- Audit log
INSERT INTO load_audit (file_name, loaded_at, row_count)
VALUES ('orders_current.csv', SYSDATE, SQL%ROWCOUNT);
COMMIT;
END;
/
A scheduled job runs the procedure nightly. The vendor only needs to copy each day's file as orders_current.csv.
Common Errors
| Error | Cause | Fix |
|---|---|---|
| ORA-29913: error in executing ODCIEXTTABLEOPEN callout | File missing, wrong permissions, or directory not granted | Verify the file exists, the OS user (typically oracle) can read it, and the user has READ on the directory |
| ORA-29400: data cartridge error / KUP-04040 | Bad format spec โ wrong column types, missing field separator | Check ACCESS PARAMETERS; compare your spec to a sample line of the file |
| KUP-04001: error opening file ... bad file | BADFILE / LOGFILE directory not writable | Grant WRITE on the directory; verify OS write permission |
| ORA-04043: object ext_data_dir does not exist | Directory object not created or grant missing | DBA creates the directory and grants READ to the user |
| Too many rows rejected; partial load | Source file format changed | Inspect BADFILE; update ACCESS PARAMETERS or fix the source |
| Slow performance on large files | No way to add indexes; every query rescans the file | Use external tables only for one-shot loads; for ongoing queries, load into a real table |
| ORA-30653: reject limit reached | More bad rows than REJECT LIMIT allows | Increase REJECT LIMIT or fix source data |
Interview Corner
โถ Show answer
Default to external tables for any new project โ they're simpler, integrate with SQL, and offer the same performance via direct-path inserts.
External tables are better when:
- The file is on the database server (or NFS-mounted)
- You want to filter or transform during load:
INSERT INTO target SELECT * FROM ext WHERE ... - You want to load into multiple tables from one source:
INSERT ALL ... SELECT FROM ext - The load is part of a larger SQL/PL/SQL workflow
- You want to query a file without committing to importing it
- The schema or transformation logic might evolve
SQL*Loader still has a niche:
- The file is on a client machine, not the database server, and you can't or don't want to grant filesystem access
- You need very specific control over per-column conversions that external tables don't expose
- Existing scripts use SQL*Loader and there's no benefit to rewriting
The Oracle documentation has moved firmly toward external tables. Most "use SQL*Loader" advice you find online predates 11g.
โถ Show answer
External tables don't store any data; the table definition is just metadata pointing at a file with a specific format. To handle a format change:
1. Drop and recreate the external table with the new format:
DROP TABLE ext_orders;
CREATE TABLE ext_orders (
...new schema or new positions...
)
ORGANIZATION EXTERNAL (
...new ACCESS PARAMETERS...
);
This is non-disruptive โ no actual data is lost because the external table held no data.
2. Use ALTER TABLE on the access parameters if the change is minor:
ALTER TABLE ext_orders ACCESS PARAMETERS (
RECORDS DELIMITED BY NEWLINE
FIELDS TERMINATED BY ';' -- changed from ','
...
);
3. Switch the LOCATION if the file name pattern changed:
ALTER TABLE ext_orders LOCATION ('orders_v2.csv');
Important: if the column types change (new column added, column dropped), you must DROP and CREATE โ ALTER TABLE ADD COLUMN doesn't apply meaningfully to external tables because the file is read positionally.
Defensive practice: keep the external table's CREATE statement in version control alongside the format spec from the source system. When the vendor changes format, update both atomically.
โถ Show answer
External tables have no indexes โ every query is a full file scan. Three options to speed things up:
1. Stop querying the external table directly.
If you're querying the file repeatedly, the file is the wrong storage. Load it once into a real table with appropriate indexes:
CREATE TABLE orders_local AS SELECT * FROM ext_orders;
CREATE INDEX orders_date_idx ON orders_local(order_date);
-- Now query orders_local instead
2. Use parallelism for the one-shot scan.
CREATE TABLE ext_orders (...) ORGANIZATION EXTERNAL (...) PARALLEL 8;
-- Or per-query:
SELECT /*+ PARALLEL(ext_orders 8) */ * FROM ext_orders WHERE ...;
Oracle splits the file scan across 8 parallel workers โ useful for one-time loads of multi-GB files.
3. Pre-filter the file with OS tools before defining the external table.
If you only need rows where status = 'CONFIRMED', run grep/awk first to produce a filtered file. External tables can't push predicates into the file; you must filter externally.
Anti-pattern to avoid: running 100 queries against the same external table in one day. Each query rescans the file. Either load it into a real table once or use a materialised view that refreshes from the external table.
Related Topics
- DDL Commands โ
CREATE TABLE ... ORGANIZATION EXTERNALis the entry point - DML Commands โ
INSERT INTO target SELECT * FROM ext_tableis the standard load pattern - Performance โ
INSERT /*+ APPEND */and parallelism for large external loads - Partitioning โ
EXCHANGE PARTITIONpaired with external tables for fastest ETL - Data Dictionary โ
USER_EXTERNAL_TABLESandUSER_EXTERNAL_LOCATIONSreference views