Skip to content
Discord Get Started

fs9 — File System

fs9 is a TiKV-backed file system accessible from SQL. It provides scalar functions for reading and writing files, a table function for querying file contents as rows, and a WebSocket API for programmatic access from the TypeScript SDK.

Files are stored in TiKV using page-based storage (16 KB pages) with inode metadata — not on a local disk. Each database has its own isolated file system.

SQL
CREATE EXTENSION IF NOT EXISTS fs9;

Only the table functions depend on this. The scalar functions work on a new database without it, but extensions.fs9(), extensions.fs9_jg(), extensions.fs9_events(), extensions.fs9_storage_stats() and extensions.fs9_cached_storage_stats() fail with ERROR: unsupported table-valued function: fs9 (0A000, naming the function you called) until the extension is created. Run it once per database.

If you use db9 fs mount on macOS, install macFUSE first:

Terminal
brew install --cask macfuse

Then approve the macFUSE system extension in System Settings > Privacy & Security if prompted. After approval, retry the mount command.

Notes:

  • This prerequisite applies to db9 fs mount only.
  • Other db9 fs commands such as fs cp, fs ls, and fs sh do not require macFUSE.
  • See the CLI Reference for the command-level mount docs.

Read an entire file as text.

SQL
SELECT extensions.fs9_read('/logs/app.log');

Returns TEXT. Returns NULL if the path argument is NULL.

Read an entire file as binary.

SQL
SELECT extensions.fs9_read_bytea('/data/image.png');

Returns BYTEA. Use this instead of fs9_read when the file contains non-UTF-8 binary data.

Write content to a file, creating it if it does not exist and overwriting if it does. Returns the number of bytes written.

SQL
SELECT extensions.fs9_write('/data/output.txt', 'hello world');
-- Returns: 11

Accepts both TEXT and BYTEA content.

Append content to the end of a file. Creates the file if it does not exist. Returns the number of bytes appended.

SQL
SELECT extensions.fs9_append('/logs/events.jsonl', '{"event":"click","ts":"2026-03-12T10:00:00Z"}' || E'\n');

Read a range of bytes from a file. Returns TEXT.

SQL
SELECT extensions.fs9_read_at('/data/large.bin', 0, 1024);
-- Reads first 1024 bytes

Both offset and length must be non-negative. Reading beyond the end of the file returns whatever bytes are available.

The range must decode as UTF-8, not just the file. If the requested bytes are not valid UTF-8 the call fails with 22021 exactly as fs9_read does, naming fs9_read_at_bytea as the fix — and a byte offset that lands mid-character is enough to trigger it on a file that is perfectly valid text. Reading aé漢字bc (11 bytes) at offset 0:

LengthResult
1a
222021 — splits the two-byte é
3aé
422021 — splits the three-byte 漢

Offsets and lengths are byte counts, so pick them on character boundaries, or use fs9_read_at_bytea and decode client-side.

Read a range of bytes from a file. Returns BYTEA.

SQL
SELECT extensions.fs9_read_at_bytea('/data/large.bin', 0, 1024);
-- Reads first 1024 bytes as binary

Same semantics as fs9_read_at, but returns raw bytes instead of text.

Write data at a specific offset in a file. Creates the file if it does not exist. Fills any gap between the current end and the offset with null bytes. Returns the number of bytes written.

SQL
SELECT extensions.fs9_write_at('/data/file.bin', 100, 'data at offset 100');

Truncate a file to a specific size. If the new size is larger than the current size, the file is padded with null bytes. Returns TRUE on success.

SQL
SELECT extensions.fs9_truncate('/logs/rolling.log', 0);
-- Empties the file

Check whether a file or directory exists.

SQL
SELECT extensions.fs9_exists('/data/config.json');
-- Returns: true or false

Return the size of a file in bytes.

SQL
SELECT extensions.fs9_size('/data/export.csv');
-- Returns: 48271

Return the last modification time of a file as an RFC 3339 timestamp (UTC).

SQL
SELECT extensions.fs9_mtime('/data/export.csv');
-- Returns: '2026-03-12T15:30:45Z'

Remove a file or directory. Returns the number of items removed. Supports glob patterns.

SQL
-- Remove a single file
SELECT extensions.fs9_remove('/tmp/scratch.txt');
-- Remove a directory and its contents
SELECT extensions.fs9_remove('/tmp/work/', true);
-- Remove files matching a glob
SELECT extensions.fs9_remove('/logs/2026-01-*.jsonl');

The second argument (recursive) defaults to false. A non-empty directory requires recursive = true.

Create a directory. Returns TRUE on success.

SQL
-- Create a single directory
SELECT extensions.fs9_mkdir('/data/exports');
-- Create nested directories
SELECT extensions.fs9_mkdir('/data/exports/2026/03', true);

The second argument (recursive) defaults to false. With recursive = true, parent directories are created as needed.

Table-valued function that returns aggregated filesystem storage statistics.

SQL
SELECT total_files, total_directories, total_logical_bytes
FROM extensions.fs9_storage_stats();
ColumnTypeDescription
total_filesINT64Total number of files
total_directoriesINT64Total number of directories
total_logical_bytesINT64Total logical size of all files in bytes

Always returns a single row. Useful for monitoring filesystem usage from SQL or building storage dashboards.

Table-valued function that returns the most recently cached filesystem statistics, plus the time they were computed. It only reads the cache; it never triggers a scan.

SQL
SELECT total_files, total_directories, total_logical_bytes, computed_at
FROM extensions.fs9_cached_storage_stats();
ColumnTypeDescription
total_filesINT64Total number of files
total_directoriesINT64Total number of directories
total_logical_bytesINT64Total logical size of all files in bytes
computed_atINT64Unix epoch seconds at which the cached values were computed — not a TIMESTAMP. Convert with TO_TIMESTAMP(computed_at)

Always returns exactly one row — never an empty result — so check the column values, not the row count. In practice the cache is already warm: a brand-new database returns zeros with a computed_at of the current time rather than NULL. Handle NULL defensively anyway, since the cold-cache path returns an all-NULL row:

SQL
SELECT CASE
WHEN computed_at IS NULL THEN 'no cached stats yet'
ELSE 'computed at ' || TO_TIMESTAMP(computed_at)::text
END AS cache_state
FROM extensions.fs9_cached_storage_stats();

The extensions.fs9() table function reads files and directories as SQL rows. It operates in three modes depending on the path.

When the path ends with /, fs9 returns directory entries:

SQL
SELECT path, type, size, mtime
FROM extensions.fs9('/logs/')
ORDER BY path;
ColumnTypeDescription
pathTEXTFull path
typeTEXT"file", "dir", or "symlink"
sizeINT64Size in bytes. Directories report 4096, not 0; a symlink reports the length of its target path, not the target’s size
modeINT64Unix permission mode
mtimeTEXTLast modified (RFC 3339 UTC)

size here is metadata about the entry, not a byte count you can sum: filtering on WHERE type = 'file' before aggregating avoids counting a flat 4096 per directory. fs9_events.size reports 0 for a directory only on a MKDIR event — its RENAME and DELETE events carry the same 4096. Use is_dir to identify directory events, never size = 0.

Use recursive => true to walk subdirectories:

SQL
SELECT path, size FROM extensions.fs9('/data/', recursive => true)
WHERE type = 'file'
ORDER BY size DESC;

Use exclude to skip files matching a pattern:

SQL
SELECT * FROM extensions.fs9('/logs/', exclude => '*.tmp');

When the path points to a file, fs9 reads its contents as rows. The format is auto-detected from the file extension:

ExtensionFormatRow schema
.csvCSV_line_number INT64, columns from header, _path TEXT
.tsvTSV_line_number INT64, columns from header, _path TEXT
.jsonl, .ndjsonJSON Lines_line_number INT64, line JSONB, _path TEXT
.parquetParquetSchema from file metadata
OtherPlain text_line_number INT64, line TEXT, _path TEXT
SQL
-- CSV with auto-detected columns
SELECT * FROM extensions.fs9('/data/users.csv');
-- JSONL as queryable JSONB
SELECT line->>'level' AS level, count(*)
FROM extensions.fs9('/logs/app.jsonl')
GROUP BY 1;

Override the format or delimiter with named parameters:

SQL
-- Force CSV format on a .dat file
SELECT * FROM extensions.fs9('/data/raw.dat', format => 'csv');
-- Pipe-delimited file without a header row
SELECT col_0, col_1
FROM extensions.fs9('/data/raw.dat', format => 'csv', delimiter => '|', header => false);

For CSV/TSV without a header, columns are named col_0, col_1, etc.

Invalid JSON lines in JSONL files are silently skipped.

When the path contains *, ?, or [, fs9 expands the glob and reads all matching files:

SQL
-- All CSV files in a directory
SELECT * FROM extensions.fs9('/data/sales/*.csv');
-- Recursive match across subdirectories
SELECT _path, _line_number, line
FROM extensions.fs9('/logs/**/*.jsonl')
ORDER BY _path, _line_number;

All matched files are read with one format and must produce one identical column list. The _path column identifies which file each row came from.

Glob parameters:

  • format, delimiter, header — same as file reading
  • exclude — glob pattern to skip (e.g., *.tmp)

Hidden files (dotfiles) are excluded unless the pattern explicitly starts with . or contains /..

extensions.fs9_jg(path TEXT, query TEXT) queries JSON Lines files directly from SQL. Each line is parsed independently and every match produces one row, so a JSONL log becomes a relation without an intermediate COPY.

SQL
SELECT _line_number, _match_path, value
FROM extensions.fs9_jg('/logs/app.jsonl', 'level');
ColumnTypeDescription
_pathTEXTFile the match came from — useful when path is a glob
_line_numberINT641-based line number within that file
_match_pathTEXTDotted path of the matched value (array indices are rendered as tags.0)
valueJSONBThe matched value

path accepts a glob, so one call can span files: extensions.fs9_jg('/logs/*.jsonl', 'level').

Supported path forms, all verified against a two-line file whose objects are {"user":{"name":…,"age":…},"tags":[…]}:

QueryMatches
user.nameone row per line — nested object member
tags[0]array index; _match_path comes back as tags.0
tags[*]every array element
user.*every member of an object
**recursive descent — the whole document plus every subpath

A key that does not exist returns zero rows rather than an error, so treat an empty result as “no match” only after confirming the path spelling.

The fs9_events() table function returns a stream of filesystem mutation events that you can query directly from SQL for custom change-tracking.

SQL
-- All events since the beginning
SELECT * FROM extensions.fs9_events();
-- Events after a stream_id you previously read back (cursor-based polling)
SELECT * FROM extensions.fs9_events(:'last_stream_id');
-- Events under a specific path prefix
SELECT * FROM extensions.fs9_events('0', '/data/');
-- Events with a custom limit (default: 10,000)
SELECT * FROM extensions.fs9_events('0', '/logs/', 5000);
ParameterTypeDefaultDescription
since_idTEXT'0'Return events after this cursor — a stream_id read back from an earlier call, or '0' for everything retained. A numeric or legacy <ms>-<seq> cursor is ignored and returns the full retained backlog; a malformed value fails (see the caution above)
path_prefixTEXTNULLFilter to events where path starts with this prefix
limitINT6410000Maximum number of event rows to return
ColumnTypeDescription
stream_idTEXTEvent cursor, v2:<milliseconds>-<sequence>:<hash> (e.g. 'v2:1790005680240-0:8a7be825c1ea93227ab02abe8ff46e48'). Pass it back unchanged as since_id
event_typeTEXTCREATE, WRITE, DELETE, RENAME, or MKDIR
pathTEXTAffected path (post-mutation)
old_pathTEXTPrevious path (only set for RENAME events)
inodeINT64Always 0 in the current release — see below
generationINT64Always 0 in the current release — see below
is_dirBOOLEANWhether the target is a directory
sizeINT64Post-mutation size. 0 for a directory on MKDIR, but 4096 for a directory on RENAME/DELETE — use is_dir, not size, to spot directory events
timestampTIMESTAMPTZEvent timestamp

Note: since_id also accepts an integer, which is converted to text. Like a legacy <milliseconds>-<sequence> cursor, it returns the full retained backlog.

SQL
-- Initial read: get the latest stream_id (psql \gset stores it in :last_stream_id).
-- stream_id compares as text, so order by its numeric millisecond and sequence parts.
SELECT stream_id AS last_stream_id
FROM extensions.fs9_events('0')
ORDER BY split_part(split_part(stream_id, ':', 2), '-', 1)::bigint DESC,
split_part(split_part(stream_id, ':', 2), '-', 2)::bigint DESC
LIMIT 1 \gset
-- Subsequent polls: pass the last seen stream_id as since_id, quoted
SELECT stream_id, event_type, path, old_path, size, timestamp
FROM extensions.fs9_events(:'last_stream_id')
ORDER BY split_part(split_part(stream_id, ':', 2), '-', 1)::bigint,
split_part(split_part(stream_id, ':', 2), '-', 2)::bigint;

The cursor contains colons, so pass it as a quoted string literal (in psql, :'last_stream_id' rather than :last_stream_id). An event can take a moment to appear after the write that caused it, so an empty poll does not mean nothing changed — keep the same cursor and poll again.

For CLI-based watching, use db9 fs watch, which handles the event subscription, gap warnings, and formatting automatically. See the CLI Reference.

fs9 supports symbolic links through the CLI FUSE mount and WebSocket API.

Terminal
# Create a symlink
ln -s /data/report.csv /data/latest.csv
# Read the symlink target
readlink /data/latest.csv

The symlink and readlink operations are available over the WebSocket protocol:

  • symlink(path, target) — create a symbolic link at path pointing to target
  • readlink(path) — return the target of a symbolic link

Symlink properties:

  • Mode: 0o777 (all permissions)
  • Type: reported as "symlink" in stat responses and in the fs9() listing
  • Size: the length of the target path, not the size of the target
  • Maximum target length: 4096 bytes

When creating files and directories, fs9 preserves Unix permission bits:

OperationDefault Mode
File create0644 (rw-r--r--)
Directory create0755 (rwxr-xr-x)
Symlink0777 (rwxrwxrwx)

Executables uploaded with db9 fs cp or written via FUSE retain their original mode bits (e.g., 0755 for shell scripts). The mode field is visible in stat responses and db9 fs ls -l output.

The jq builtin in db9 fs sh supports JSON Lines (JSONL) format — when input contains multiple JSON objects (one per line), the filter is applied to each object independently:

Terminal
db9 fs sh mydb -c "cat /logs/events.jsonl | jq '.event'"
Output
click
pageview
submit
SQL
-- Write a log entry (the leading E'\n' guards against a file whose last line has no newline)
SELECT extensions.fs9_append(
'/logs/agent.jsonl',
E'\n' || '{"ts":"2026-03-12T10:00:00Z","action":"search","query":"revenue"}' || E'\n'
);
-- Query log entries
SELECT line->>'action' AS action, count(*)
FROM extensions.fs9('/logs/agent.jsonl')
GROUP BY 1 ORDER BY 2 DESC;
SQL
SELECT cron.schedule('daily-report', '0 6 * * *', $$
SELECT extensions.fs9_write(
'/reports/daily-' || to_char(now(), 'YYYY-MM-DD') || '.csv',
(SELECT string_agg(id || ',' || item || ',' || total, E'\n')
FROM (SELECT id::text, item, sum(amount)::text AS total
FROM orders WHERE created_at > now() - interval '1 day'
GROUP BY id, item) t)
)
$$);
SQL
SELECT extensions.fs9_write(
'/imports/feed.json',
http_get('https://api.example.com/feed')->>'content'
);
SQL
-- List files older than 7 days
SELECT path, mtime FROM extensions.fs9('/logs/')
WHERE type = 'file'
AND mtime < to_char(now() - interval '7 days', 'YYYY-MM-DD"T"HH24:MI:SS"Z"');
-- Remove them
SELECT extensions.fs9_remove('/logs/2026-02-*.jsonl');

All fs9 scalar functions and the table functions require superuser privileges. The default admin role is a superuser. Regular database users created via db9 db users create cannot use fs9.

When accessing fs9 via the WebSocket API (used by the TypeScript SDK), connect keys control access:

ScopePermissions
fs9:roRead-only: stat, read, readdir, size, mtime, exists
fs9:rwRead and write: all operations

Connect keys are created via the REST API:

Terminal
curl -X POST https://api.db9.ai/customer/databases/<id>/connect-keys \
-H "Authorization: Bearer $DB9_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "agent-fs", "scopes": ["fs9:rw"]}'

The TypeScript SDK uses a WebSocket connection for fs9 file operations. This provides streaming support for large files and avoids SQL overhead for file I/O.

The WebSocket server listens on port 5480 by default. The protocol uses JSON frames with an op field and a request id for correlation.

Operations verified to answer successfully on the current release:

GroupOperations
Sessionauth
Metadatastat, readdir, readdir_recursive, chmod
Filesread, write, pwrite, append, truncate
Namespacemkdir, unlink, rm, rename, symlink, readlink
Batchbatch_stat, batch_inline_read, batch_write
Watchwatch_subscribe, watch_unsubscribe

The rest of the protocol is parsed but does not currently complete. batch_write_atomic returns ENOSYS (batch_write_atomic is not supported by this backend); the multipart-upload family — create_upload, presign_part, presign_parts, complete_upload, abort_upload — and prepare_download all return EIO (FS9 operation failed), including for well-formed requests. Treat them as unavailable until they answer ok, and upload large files with write, which streams.

An op the server does not recognise at all is not reported as an error: the connection is closed instead, so a typo in op looks like a dropped socket rather than a bad request. The request id must be a JSON string, but an integer id is not reported as an error either: like an unrecognised op, and like any malformed frame, it closes the connection. Every protocol-level mistake surfaces as a dropped socket rather than a response.

Files larger than 1 MB are automatically streamed in 64 KB chunks.

Connection limits:

  • Auth timeout: 10 seconds
  • Idle timeout: 5 minutes
  • Max connections per tenant: 50
  • Max JSON frame: 2 MB

See the TypeScript SDK for the client-side API.

LimitValue
Max file size100 MB
Max total bytes per glob query100 MB — files past it are dropped silently
Max files per glob expansion10,000 — a larger match silently reads only 10,000 of the files
Concurrent read budget (scalar functions)128 MB
Page size (internal storage)16 KB
Write stream flush threshold256 KB
WebSocket max connections per tenant50
WebSocket idle timeout5 minutes
WebSocket max JSON frame2 MB
WebSocket streaming chunk size64 KB
ErrorSQLSTATECause
fs9: permission denied (superuser required)42501Non-superuser role called one of the 13 scalar functions. The 5 table functions report the same denial as XX000 — see Permissions
unsupported table-valued function: fs90A000A name was used in FROM position that is not a table function there. Either a table function called before CREATE EXTENSION fs9, or a scalar function used in FROM with the extension installed (SELECT * FROM extensions.fs9_read(...)). The message names the function you called
fs9 resource was not found58P01The path does not exist — including the parent directory of a write
fs9 resource has the wrong object type42809The path exists but is not the kind of object the call needs (reading a directory, for example)
fs9 resource already exists58P02Creating something that is already there — e.g. fs9_mkdir on an existing directory
fs9 directory is not empty42809fs9_remove on a directory that still has entries; pass true as the second argument to recurse
permission denied for fs9 resource42501The role may use fs9 but not this particular resource
stored value exceeds server limit54000A scalar call moved more than 100 MB in one go — fs9_write with a larger payload, or fs9_read of a larger file
fs9 resource exceeds the supported size limit54000The table function was pointed at a file larger than 100 MB. Same SQLSTATE as the row above but a different message, so match on the code, not the text. The WebSocket path spells its equivalent FS9 resource…, but WS read streams large files rather than rejecting them
file is not valid UTF-8; use fs9_read_bytea for binary data22021fs9_read/fs9_read_at on bytes that are not valid UTF-8. Names the path and the _bytea variant to use
invalid fs9 request22023The path is not a valid target for the call — reading a symlink with fs9_read or the fs9() table function, for example
internal errorXX000Argument validation failures (a negative fs9_read_at offset or fs9_truncate size), a glob whose files disagree on columns, a table function called without superuser, and I/O faults all surface this way, with no detail

Argument-validation messages are not passed through to the client. fs9_truncate still builds the text fs9_truncate: size must be non-negative internally, but the wire only ever carries internal error, so do not match on it.

fs9 is also accessible from Serverless Functions via ctx.fs9 — you can read and write files from your deployed JavaScript/TypeScript code without going through SQL. See the Serverless Functions runtime for details.