ted
September 17th, 2025
Why isn’t there a terminal editor for database tables? In development, I often want to check the database without repetitively SELECT or a quick change without typing a full UPDATE.
What’s the challenge? My time at Numeracy tells me it’s always caching. Your database client should always have up-to-date data. There’s no consistent way to detect changes on a table across databases without modifying the database (streaming changes over). Yet, a database client should not require anything more than read permissions.
The only answer is caching and polling. However, the bigger the cache, the bigger the caching problem. Get only what’s need for display. Of course, this means you need to sort, as databases don’t have a default sorting mechanism.1 That means we need some kind of stable lookup key; preferably primary key but optionally a non-nullable or nulls not distinct unique constraint. The use case is here administrative and not analytical.2
It’s a sliding window into the database. As such, it is always ordered by primary key. select * from t where id > $1 order by id limit $2
If you add ORDER BY created, there is a hidden order by created, x. You can add WHERE clauses. They are executed as WHERE (user-where-clause) AND id > ? when paging.
ted --pg blog posts --where tags=tech
Polling every 200ms, ideally visually indicating changes, especially since you’re only getting a few hundred rows. You’d want visual indicators for 🟩 insert/update, 🟥 delete. You only need to poll for on screen rows.
You also may not want to overwrite changes. When saving a value to database, UPDATE posts SET title = $1 WHERE id = $2 AND title = $3. If no row is updated, pop a confirmation dialogue to overwrite.
Target use case
The ted focus is viewing and editing values during software development, which is largely local, row-wise, and small datasets. ted is most unsuited to columnar database servers, where incrementally loading rows is highly inefficient.
For columnar databases, loading whole columns is comparatively cheap, so column selection is more important. Namely, if a table has 30 columns and the viewport can only display 8 of them, it may be better to only select 8 and load additional ones on scroll.
Spreadsheet-like navigation
There are several types of navigation that have different effects on moving the viewport vs cursor.
| action | viewport | selection |
|---|---|---|
| arrow | ✔ | ✔ |
| home/end | ✔ | ✔ |
| goto | ✔ | ✔ |
| page | ✔ | ✔* |
| scroll | ✔ |
* When you page down, you expect the selection to remain in the same screen position, not spreadsheet position.
Unlike scrolling documents, where the height of the document can be trivially calculated, you cannot know the row number in a database table without a O(n) query. In fact, the database is an independent data layer, so untracked mutations can happen without notifying ted, so even if we pull the count, that may change over time. We’re not turning pages in a book; we’re watching highway traffic video feeds.
| approach | advantages | disadvantages |
|---|---|---|
| scroll after load | no risk of over-scrolling | laggy scrolling |
| before/after index | ||
| primary key index | scroll confidently, fetch only ranges needed instead of iterating | larger indexes are slow, especially over network |
| row count | protect the “bottom” from falling out | you end up doing a index scan anyways, just over a smaller portion |
I dislike the idea of an editor silently pulling data that the user may not need, simply for reduce scrolling latency, which the user also may not need. It’s better to simplify the model, accept scrolling latency. Because we are connected directly to the database and are doing a (relatively) straightforward scan, latency should largely be <10ms.
Mouse scrolling does feel noticeably worse with lag beyond 30ms. However, fetching non-TOAST rows is pretty cheap, <1ms, so long as you keep the query open, scrolling latency should be well below the threshold. If scrolling does not continue within 50ms, rows.Close(). Then start refreshing the current view every 200ms.
Selection
If you move the viewport with page or scroll and then move the cursor (with the arrow), the expectation is the move the minimal amount (aka if the viewport is below the cursor). The same behavior is expected with goto: if the target row is above the current viewport, the cursor should move to the top. If the cursor is in the current viewport, do not scroll.
Goto
Because goto is a jump (and not scrolling), it can tolerate loading delay. If the id is not in view but is below, cursor jumps to the bottom; if above, top.
If you’re in the middle of the table, goto scrolling requires understanding whether the key is, which inherently requires understanding order. The best way is to directly do the comparisons in SQL, especially if we allow more complex ORDER BY.
Scrolling up requires reverse sort and load a page of rows. This requires some level of ORDER BY clause parsing, reversing each sort expression. ORDER BY random() is not allowed obviously.
Hidden and primary columns
Hidden columns are not selected. If you want to select columns but not show them, move to the right, off screen. Because hidden columns are not selected but primary key must always be selected, it’s clearer to freeze it. They’re row number equivalents, it’s spreadsheet-like to do this.3
What about tables without a primary key? If a unique index exists, choose one on open (optionally saved to config). It’d be unusual for a table to have unique indices (especially either not null uniques or unique nulls not distinct) but no primary key; why not just designate that your primary key? Tables without any identifying columns should just be read-only.
When a column is hidden, they’re pushed to a hidden ◂ column at the end. All columns are shown unless explicitly hidden.
-
Postgres has cursors, but that’s not common. Limit offsets don’t work if the table underneath you is modified. ↩
-
Tables without any primary keys are event logs and are probably better
tailed than edited. ↩ -
Default format for
intshould be2:(2 width, right align), especially for the “primary key” case. ↩

