Data management¶
Introduction¶
This guide covers getting data into a psycodict database and maintaining it: creating tables, writing rows, the bulk file-based import/export tools that psycodict is built around, and the statistics machinery. For reading data out (the dictionary-to-WHERE translation used by search, count, lucky and lookup), see QueryLanguage.md.
Everything here is a method on either the database object (db) or a search table (db.mytable). psycodict was written for the LMFDB, where tables are large and change rarely, so the design favors bulk file operations and cached statistics over row-by-row mutation.
A note on permissions: most write methods first check db._read_only, and several branches of behavior depend on whether your table’s statistics class has saving turned on (see Statistics and counts). A bare psycodict install has saving = False.
Creating tables¶
create_table¶
db.create_table(
"demo_circles",
{"integer": ["radius"], "text": ["label"], "integer[]": ["center"], "jsonb": ["props"]},
label_col="label",
sort=["radius"],
)
namemust contain an underscore. The call also creates the companion tablesname_countsandname_stats, and writes one row tometa_tables.search_columnstakes two forms: a dictionary whose keys are Postgres types and whose values are lists of column names (a bare string is allowed for a single column), or a list of(column, type)pairs. The two forms are interchangeable.the
idcolumn is added automatically as abigintprimary key unless you already list one; useid_type=to choose a different integer type. Columns are physically laid out ordered by type (widest alignment first) for storage efficiency, so the on-disk column order is not your declaration order — but every file operation is header-driven, so this never matters to you.label_colnames the column used bylookup; it must be one of the search columns, orNone.sortis the default sort order: a list of column names or(column, 1|-1)pairs.id_ordereddefaults toTruewhensortis given,Falseotherwise. It records the intent that, in production, theidcolumn runs in the same order assort(which lets some range queries use the primary-key index). It does not sort anything now; see resorting is disabled.
The most common Postgres types are smallint/integer/bigint, numeric (exact), real/double precision, text, boolean, jsonb, timestamp, and the array forms (integer[], numeric[], …).
create_table_like¶
db.create_table_like("demo_copy", "demo_circles") # schema only
db.create_table_like("demo_copy", "demo_circles", data=True, indexes=True)
Copies the column layout, label_col, sort, id_ordered, id type and descriptions from an existing table. By default it copies no data, no indexes and no statistics; pass data=True and/or indexes=True to include them.
The meta tables¶
psycodict keeps its own bookkeeping in a handful of tables that live alongside your search tables:
meta_tables— one row per search table, holdingname,sort,count_cutoff,id_ordered,out_of_order,stats_valid,label_col,total,importantandinclude_nones. This is the source of truth psycodict reads on connection to reconstruct each table object.meta_indexesandmeta_constraints— one row per index / constraint, recording how to rebuild it.reloadandrestore_indexesrebuild from these rows, not from whatever is physically on the table (see reload).meta_tables_hist,meta_indexes_hist,meta_constraints_hist— versioned history of the three tables above, so thatreload_meta/revert_metacan roll a table’s metadata forward and back.meta_format— a single-row(version, min_compat)stamp of the metadata format: the layout of themeta_*tables, versioned by an integer aligned with psycodict’s major version (META_FORMAT). Every connection checks it. The same format connects silently; an older but compatible format connects with a warning and operates at that older format (newer features unavailable) so a not-yet-migrated or read-only database — the LMFDB devmirror, say — keeps working; a newer format connects when its stampedmin_compatadmits this psycodict, and is otherwise refused. A database that has meta tables but nometa_formatis the unstamped 0.x baseline (format 0); one with no meta tables at all is fresh and must be connected to withPostgresDatabase(create=True), which bootstraps all of the above. Migrating to a newer format is deliberate —db.upgrade_metadata()orPostgresDatabase(upgrade=True), never a side effect of connecting. See MetadataFormats.md for the full policy (including why a pre-1.0 psycodict must not be pointed at a migrated database).
Row-level writes¶
These mutate the live table directly. They are convenient for small edits; for anything large prefer the file operations. Each records an entry in the change log and, on success, updates the maintained row total.
insert_many(data, resort=False, reindex=None, restat=True)—datais a list of dictionaries that must all have the same keys. Each row is assignedid = max_id() + 1(so the first row of an empty table getsid = 0), and the caller’s dictionaries are updated in place with the assigned ids.reindexdefaults toTruewhen there are more than 1000 rows, dropping the indexes and primary key around the insert and rebuilding them after — if an exception interrupts this you must callrestore_indexesyourself. Faster than repeatedupsert, slower thancopy_from.upsert(query, data)— updates the single row matchingquery, or inserts one if none matches; raises ifquerymatches more than one row. Returns(new_row, row_id). You cannot setid. A new insert usesid = max_id() + 1.update(query, changes, resort=False, restat=True)— a plain SQLUPDATEof every row matchingquery;changesmaps column names to constants.delete(query, restat=True)— deletes every row matchingqueryand decrementstotal.
Statistics invalidation. Any write that can change the data calls _break_stats, which sets meta_tables.stats_valid = false so that cached statistics are known to be stale. If the table has saving on and you left restat=True, statistics are refreshed at the end of the call; otherwise they are simply marked invalid. Inserting rows (and updating a sort-key column) also calls _break_order, setting out_of_order = true to record that the id order no longer matches sort; delete leaves the order flag alone.
Resorting is disabled¶
resort() is a no-op on this branch: it prints resorting disabled and returns None without touching the table. In-place resorting was found to stall replication and to not persist correctly on disk, and since the tables are effectively read-only in production the supported way to renumber ids is to dump the data in sorted order and reload it. Consequently the resort= keyword on insert_many, update, copy_from, reload and update_from_file currently does nothing.
Locking¶
Before mutating, each method calls _check_locks, which asks Postgres (via pg_locks) whether any other session holds a lock that conflicts with the operation (inserts, updates and deletes each map to their own set of conflicting lock modes). If so, it prints the offending lock types and process ids and raises LockError:
AccessExclusiveLock 51870
LockError: Table is locked. Please resolve the lock by killing the above processes and try again
Use db.show_locks() to see who holds what, and resolve the conflict before retrying.
Bulk file operations¶
This is the heart of psycodict. The workflow is: copy_to a table (or the current data) to a file, and copy_from/reload/update_from_file a file back in. The file format is shared by all of them.
The file format¶
A search file begins with three header lines followed by one line per row:
the column names, separated by the delimiter (default
|), withidfirst if present;the Postgres type of each column, in the same order;
a blank line.
The reader (_read_header_lines/_check_header_lines) checks that these names and types match the table before loading, so the file is self-describing and column order is whatever the header says. The default delimiter is | (do not use a comma) and the default null marker is \N. Values are written by Postgres’ COPY (mirrored on the write side by copy_dumps, which rewrite uses):
text is written literally, with the delimiter, newlines and backslashes escaped; a whole-column SQL
NULLbecomes\N;numbers,
date/timestampand booleans (t/f) are written as literals;arrays use Postgres array syntax
{1,2,3}; the empty array is{}, aNULLarray is\N, and aNULLelement inside an array is the bare wordNULL;jsonbis written as JSON text — note Postgres normalizes it, so keys come back reordered and whitespace regularized.
Here is a genuine two-row export (db.demo_circles.copy_to("demo_circles.txt")) of the table created above:
id|center|label|props|radius
bigint|integer[]|text|jsonb|integer
0|{0,0}|unit|{"unit": true}|1
1|{3,4}|big|{"note": "3-4-5", "unit": false}|5
Note the id column first, the integer[] written as {0,0}, the jsonb keys reordered by Postgres, and (had a value contained a |) it would be escaped as \|.
copy_to and copy_from¶
copy_to(searchfile, countsfile=None, statsfile=None, indexesfile=None, constraintsfile=None, metafile=None, columns=None, query=None, include_id=True) exports via COPY ... TO STDOUT. Only the search file gets the three header lines; the counts, stats, meta, index and constraint files are headerless raw rows (their column layout is fixed and known). query restricts which rows are exported; columns restricts which columns. The counts and stats files are written only if the table has saving on.
copy_from(searchfile, resort=False, reindex=None, restat=True) streams a search file into the existing table via COPY ... FROM STDIN, appending rows. It validates the header, and if the file has no id column it assigns ids contiguously starting just after the current maximum (using a temporary sequence). reindex defaults to True for files with more than 1000 data rows. total is incremented and statistics invalidated.
reload¶
reload(searchfile, countsfile=None, statsfile=None, indexesfile=None, constraintsfile=None, metafile=None, ...) is the safe way to replace a table’s contents. It is a clone-build-swap:
clone the live table’s schema into
name_tmp(viaCREATE TABLE ... (LIKE ...), which copies the column names, their types andNOT NULLconstraints, but not defaults, storage settings, indexes, constraints or the primary key);COPYthe file intoname_tmp;restore the primary key and rebuild indexes/constraints on
name_tmpfrommeta_indexes/meta_constraints(or fromindexesfile/constraintsfile);final swap: rename the live
nametoname_old<n>andname_tmptoname.
Because of the swap, the previous table survives as name_old<n>, where <n> is the next unused backup number — the live table is never mutated in place, so a failed load leaves the original untouched. A second reload produces name_old2 (and warns you that backups are accumulating). When saving is on, the _counts and _stats tables are cloned and swapped the same way.
Sharp edges — what reload does not preserve:
indexes and constraints are rebuilt from the
meta_indexes/meta_constraintscatalog, not from whatever is physically on the live table. An index that exists on the table but is missing from the catalog is silently lost.the
meta_tablesrow (sort,label_col,id_ordered, …) is updated only if you pass ametafile; otherwise it is kept as-is.old ids survive only if the search file contains an
idcolumn — whichcopy_towrites by default (include_id=True).
Pass final_swap=False to build name_tmp without swapping (then finish later with reload_final_swap), or adjust_schema=True to build the temp table’s columns from the file header rather than requiring them to match the current schema.
Reverting and cleaning up¶
reload_revert(backup_number=None)swaps the live table withname_old<n>(the most recent backup by default); calling it twice returns you to where you started. It refuses to run while aname_tmptable is still present — a sign a reload did not finish — and tells you todrop_tmpfirst.drop_tmp()drops the_tmptables left behind by an interrupted reload.cleanup_from_reload(keep_old=0)drops the_tmpand all_old*backup tables (keeping thekeep_oldmost recent, renumbered from 1). Running this makesreload_revertimpossible, so do it only once you are confident in the new data.
update_from_file and rewrite¶
update_from_file(datafile, label_col=None, *, inplace=False, resort=None, reindex=None, restat=True) updates existing rows. Unlike reload, the file need only contain a subset of the columns; its first column must be the key that identifies rows (label_col, defaulting to the table’s label column), and the remaining columns are overwritten on the matching rows. You cannot update id. By default (inplace=False) it builds the updated table by cloning and swapping, so it leaves a name_old<n> backup and is revertible with reload_revert; inplace=True does a direct SQL UPDATE (faster, but not revertible). The parameters after label_col are keyword-only. reindex is meaningful only with inplace, where it drops the indexes touching the updated columns and rebuilds them afterward (defaulting to that when more than 1000 rows change); the clone-and-swap path always rebuilds every index, so reindex=True there is redundant and reindex=False raises.
For example, a file whose header is label|m (plus a type line and a blank line) with body l1|111 / l3|333 sets column m on rows l1 and l3 and leaves every other column, and every other row, untouched.
rewrite(func, query={}, ...) reads every row matching query, applies func(record) -> record in Python, writes the results to a temporary file and feeds it through update_from_file. Use it to transform columns wholesale or to populate a new column — but call add_column first, since rewrite will not add columns for you.
Statistics and counts¶
Every search table has two companion tables:
name_countscaches result counts: columnscols(a JSON array of column names),values(a parallel JSON array of the values queried),count,extra(whether this count came from an ad-hoc user search rather than a systematic pass), andsplit.name_statsstores aggregates: columnscols,stat(total,avg,min,max,distinct, …),value,constraint_cols,constraint_values, andthreshold.
stats.saving. The default PostgresStatsTable has saving = False, meaning psycodict does not persist counts or statistics — the companion tables exist but stay empty. To turn persistence on you subclass PostgresStatsTable with saving = True and point your search-table class’s _stats_table_class_ at it (the LMFDB does exactly this). Almost every branch below that mentions “recording” is gated on saving.
The total is special. The row count for the empty query is maintained in meta_tables.total on every write path regardless of saving, and count({}) is answered from it in O(1). So the grand total is always cheap and always available even with saving off.
Building stats. add_stats(cols, constraint=None, threshold=None, split_list=False) records, for each distinct tuple of values of cols occurring at least threshold times, its count into name_counts; for a single numeric column it also records total/avg/min/max into name_stats. refresh_stats(total=True) recomputes everything currently recorded — it uses the existing total stat rows to know which statistics to regenerate, and preserves the extra = true counts accumulated by user searches.
Count caching contract (from the current count docstrings). A non-joined count(query, record=True) first consults the name_counts cache (quick_count); on a miss it runs a real SELECT COUNT(*) and, when record and saving are both true, records the result back into name_counts. With the default saving = False this means nothing is ever cached — every count is a fresh scan (except count({}), always answered from total). A joined count (count(query, join=...)) is computed directly and never cached, so record is ignored there. count_cutoff (a meta_tables column, default 1000) is the threshold above which search pages report a cached or bounded count rather than paying for an exact scan.
Operational tips¶
db.show_locks()prints every lock currently held on any table — name, mode, pid and age. This is how you find the process id to kill when a write raisedLockError.table.analyze(query, projection=1, limit=1000, sort=None, explain_only=False)prints the SQL it would run and itsEXPLAIN ANALYZEplan (or justEXPLAIN, withexplain_only=True). Use it when tuning indexes for a slow search.Slow-query logging is configured in
config.ini:[logging] slowcutoff(seconds, default0.1) andslowlogfile(defaultslow_queries.log). Any statement slower thanslowcutoffis logged with its interpolated SQL; slow searches additionally log aReplicate with db.table.analyze(...)hint so you can reproduce them.On PostgreSQL 18+ a
reloadorupdate_from_fileprints a harmless warning likeConstraint of ... with name ..._id_not_null does not end with the suffix _tmpduring the swap — Postgres now catalogsNOT NULLas a named constraint that the swap logic does not recognize. The constraint is preserved; the warning can be ignored.