Configuration reference
One TOML file describes the whole pipeline. Unknown keys are rejected at load time, so a typo fails immediately instead of silently doing nothing.
You do not have to write it from this page. pg2osync init --table users writes
the smallest config that runs, qualifying the table name from the source's own
catalogue and declaring a table with no primary key append_only; this
reference is for the options you add afterwards. Every command defaults to
pg2osync.toml, which is what init writes.
Full example: examples/pg2osync.example.toml.
Everything structural is checked by pg2osync validate, which also connects to
both ends and verifies server prerequisites.
Secrets
Credentials belong in environment variables. Every secret has an *_env form:
[source]
url_env = "PG2OSYNC_SOURCE_URL" # preferred
[target]
password_env = "PG2OSYNC_TARGET_PASSWORD"
Plain url and password keys still work but log a deprecation warning on
startup. Secrets never appear in logs or error messages.
[source]
| Option | Default | Description |
|---|---|---|
flavor | "postgres" | "postgres" or "mysql" (also covers MariaDB) |
mode | "wal" | "wal" (replication log) or "poll". PostgreSQL only |
url_env | — | Environment variable holding the connection URL |
url | — | Inline URL; warns as deprecated |
sslmode | from the URL, else prefer | disable, prefer, require, verify-ca, verify-full |
sslrootcert | — | PEM bundle of trusted roots for the verifying modes |
sslcert | — | PEM client certificate chain presented to the server; requires sslkey |
sslkey | — | PEM private key for sslcert; PKCS#8, RSA or EC, unencrypted |
admin_url_env | falls back to the source URL | Separate connection for catalog and nested-child queries |
reconnect_max | 10 | Consecutive stream failures tolerated before exiting; 0 exits on the first |
reconnect_backoff_ms | 1000 | Initial reconnect delay, doubled per failure, capped at 30 s |
load_workers | 1 | Ranges of the initial load read at once, each on its own connection. PostgreSQL only. Worth raising only for tables with nested children, where the server does per-row work: measured +53% there, +5–8% on ordinary tables for four times the read load |
load_chunk_rows | 50000 | Rows one initial-load piece covers: a sampled range on PostgreSQL, a keyset chunk on MySQL. On PostgreSQL it is also how often the load can react to WAL pressure, since a range cannot be interrupted |
slot_name | "pg2osync" | PostgreSQL replication slot |
publication | "pg2osync_pub" | PostgreSQL publication |
server_id | 424242 | MySQL: replica id, unique across the server's replicas |
poll_column | "updated_at" | Poll mode: default timestamp column |
poll_interval_secs | 30 | Poll mode: seconds between cycles |
poll_page_size | 5000 | Poll mode: rows per table per cycle |
URL formats:
postgres://user:pass@host:5432/dbname
mysql://user:pass@host:3306/dbname
Percent-encoded credentials are decoded, so a password containing @ or :
works if you encode it.
admin_url_env exists so the replication connection and ordinary queries can
use different users — the replication role needs REPLICATION, the admin role
needs SELECT on the synced tables.
It is also the only one of the two that may sit behind a connection pooler;
the stream cannot, and both must reach the primary — see
Proxies and connection poolers.
TLS
sslmode follows libpq exactly, and applies to every connection pg2osync opens
— the replication stream and the MySQL binlog dump included, so a source can
never end up half encrypted.
| Mode | Encrypted | Certificate checked | Hostname checked |
|---|---|---|---|
disable | no | — | — |
prefer (default) | if the server offers it | no | no |
require | yes | no | no |
verify-ca | yes | yes | no |
verify-full | yes | yes | yes |
An explicit sslmode in the config wins over one in the connection URL, so a
URL pasted from a provider cannot weaken a deployment that pinned its mode.
prefer is the default because libpq uses it and it improves an unconfigured
deployment without breaking a server that has no certificate. It is not a
guarantee: a server that does not offer TLS is silently accepted. Anything
crossing a network you do not control wants verify-full.
With verify-ca and verify-full, sslrootcert points at the CA bundle; when
it is omitted the bundled Mozilla roots are used, which is what public managed
providers chain to.
sslcert and sslkey are the other direction: the certificate this process
presents so the server knows who is connecting, for a PostgreSQL pg_hba.conf
with clientcert=verify-full or a MySQL account declared REQUIRE X509. Set
both or neither — half a client identity is refused before anything connects.
The key must be unencrypted, in PKCS#8, RSA (PKCS#1) or EC (SEC1) form; one
file holding both the chain and the key works for both options. They apply to
every connection the process opens: the replication stream, the catalog and
admin queries, and the initial load.
This is orthogonal to sslmode. require plus a client certificate is a real
combination — encrypt, prove who I am, do not check who you are — and is what a
self-signed managed instance that still demands a client certificate needs. The
URL may carry sslcert= and sslkey= as well; the config wins per option.
Poll mode
For managed PostgreSQL instances where logical replication cannot be enabled. It re-reads rows whose timestamp column advanced since the last cycle.
- Deletes are invisible. There is no log to read them from.
- Requires a monotonically increasing timestamp column per table.
- Each start re-runs the initial load: there is no position to resume from, and re-indexing is harmless under idempotent writes. Existing WAL checkpoints are ignored in this mode so a gap can never be skipped.
- A row whose
id, index template orroutingcolumn changed leaves its old document behind: a cycle sees the row's new state and has no before-image to find the old one by.
[target]
| Option | Default | Description |
|---|---|---|
flavor | "opensearch" | "opensearch", "elasticsearch" or "meilisearch" |
url | (required) | Base URL, e.g. http://localhost:9200 |
username | — | Basic-auth user |
password / password_env | — | Basic-auth password |
api_key_env | — | Elasticsearch API key, or Meilisearch master key |
tls_verify | true | Only disable for self-signed development certificates |
state_dir | ./.pg2osync-state | Meilisearch only: directory for the checkpoint file |
Meilisearch has no place to store an arbitrary document, so its checkpoint is a local file. Give that directory persistent storage, or a restart re-runs the initial load.
[sync.<key>]
One section per table. <key> is the index name when index is omitted.
| Option | Description |
|---|---|
table | Required. schema.table for PostgreSQL, database.table for MySQL |
index | Target index or collection; lowercase [a-z0-9_-], not starting with _ or .; several sections may name the same one, see Sharing an index; may contain {column} placeholders, see Per-row indices |
primary_key | Overrides key detection; also the join column for nested children; contradicts append_only |
append_only | The table has no key and only ever gains rows; documents are filed under a content hash, see Append-only tables |
id | Derived document id, e.g. tenant-{tenant_id}-{id}; see Document ids |
fan_out | One row becomes one document per element of an array column; see Fan-out |
join | This table's place in a join field shared with another section: its relation name and, on the child, the parent column; see Join fields |
columns | Only these columns are indexed |
exclude_columns | All columns except these; mutually exclusive with columns |
transform | Map of column to an operation, see Transforms |
fields | Map of source column to target field name; applied last, see Field names |
constants | Map of field name to a literal value added to every document; {schema}/{table} in a string render at startup, see Constant fields |
where | Restricted SQL predicate deciding which rows are indexed, e.g. status = 'active' AND deleted_at IS NULL; see Row filters |
poll_column | Poll mode: overrides [source] poll_column for this table |
soft_delete | SQL predicate marking a row as deleted, e.g. deleted_at IS NOT NULL |
mapping_file | JSON mapping to create the index with, see below |
pipeline | Ingest pipeline the target runs on every document of this section, e.g. "embed-products"; OpenSearch and Elasticsearch only, see Ingest pipelines |
routing | Column whose value decides the shard this section's documents live on, e.g. "tenant_id"; OpenSearch and Elasticsearch only, see Routing |
children | Nested child collections, see below |
Projection and transforms apply to every path — initial load, live streaming and
poll mode — so an excluded column never reaches the target. The primary key is
read before projection, so excluding a key column is rejected at load time
(it would collide document ids). Ids, likewise, render from the row's raw
values: before projection and before transforms. fields renames run after
projection and transforms, and constants are added after that; every other
option names the column as the source knows it.
Document ids
By default a document's _id is its row's primary key, exactly as it always
has been — configuring nothing changes nothing, and an existing index needs no
rebuild. A table with no key can still be synced insert-only, under a hash of
the row, see Append-only tables. id overrides the
shape:
[sync.orders]
table = "public.orders"
id = "tenant-{tenant_id}-{id}"
Literals plus {column} placeholders. The name has to be a column of the
table, and the value renders like a key does: strings unquoted, numbers and
booleans as text.
- A NULL in a column the id references halts the pipeline — the id cannot
be invented, and the document the row already owns would be stranded.
validatewarns up front for nullable columns. - An id naming only key columns works everywhere. An id that references
columns outside the key needs the row's before-image to delete and move
its documents, so on PostgreSQL the table must be
REPLICA IDENTITY FULL;runrefuses to start otherwise. MySQL already guarantees it (binlog_row_image = FULL).
Append-only tables
A table with no primary key can be synced as long as it only ever gains rows — an event log, an audit trail, a metrics table. Declare it:
[sync.events_log]
table = "public.events_log"
append_only = true
Without a key nothing can say which document a row is, so the document id is
a content hash: sha256 of the row's raw values as canonical JSON, hex,
32 characters. The same row hashes the same on the initial load, the stream
and in poll mode, so a replay lands on the document it already wrote — and
two identical rows are one document, which is the right answer for an
append-only table. If the table carries a unique column such as an
event_id, set id and the document is named from it instead.
- An
UPDATEorDELETEon the table halts the pipeline:public.events_log: an UPDATE arrived on an append-only table; nothing can say which document it is. There is no document to move or remove, so the pipeline stops at that change rather than guess; an append-only table is one on which it never arrives. where,columns,exclude_columns,transform,fields,constants,indextemplates andpipelineall work. A row that awherefilter excludes is deleted under its own hash, which is a no-op on the first pass.primary_keycontradicts the declaration and is refused; so arefan_out,join,[[children]]andsoft_delete, each of which needs a key to address a document by.reconcilerefuses an append-only table — it pages the index by a key column the table does not have.resnapshotworks and writes the same hashes, and so doesreindex, which checks its count asdocuments <= rows: rows the source cannot tell apart are one document.initwritesappend_only = truefor a table it finds without a primary key, so the generated config runs unedited.
Sharing an index
An index built before pg2osync is usually a union of several tables, and several sections may name the same index:
[sync.users]
table = "public.users"
index = "search"
id = "user-{id}"
[sync.orders]
table = "public.orders"
index = "search"
id = "order-{id}"
- Every section sharing the index declares an
id. The default id is the row's key, and two tables that both have a row1would be one document by accident; an explicit template on each section is the declaration that they are not. A shared index with a section that omitsidis refused at config load. The templates themselves are not compared:user-{id}on both sections collides, and that is the operator's own declaration — nothing checks the values, because nothing can see them. - At most one of the sections sets
mapping_file. An index is created once; a second section describing it is refused. reconcilerefuses a shared index. It pages the index by one table's key column and cannot tell one table's documents from another's, so every other table's documents would be reported as orphans — and removed by--delete.TRUNCATEon any of the tables is not applied. Clearing the index would wipe the tables the source never truncated, and halting would replay the sameTRUNCATEfrom the slot at every restart with nothing to change to get past it — so the truncated table's documents are left in place, the pipeline logsTRUNCATE not applied to index search, which other tables also feed; its documents are left in placeand counts atruncate_skippedevent inpg2osync_events_total. Clear them by hand, or give the table an index of its own. A join pair is different: its halves are told apart by the join field, so a truncate there clears one relation exactly.resnapshotworks on any one of the tables for the same reason: it writes by id and touches nothing else in the index.reindexrefuses a shared index, including a join pair's. It reads one table, so the fresh index it built would hold that table's documents and nothing else — and the alias would then hide the others. Rebuild a shared index with a second instance of the whole config.
A join pair is the other way two sections share an index:
there the join field scopes every document to its relation, which is why
reconcile can check either half of the pair against its own table.
Per-row indices
index may carry {column} placeholders, so each row chooses the index it
lands in. Two shapes cover most of what this is for. Time-based retention,
where an old month is dropped as one index instead of deleted row by row:
[sync.events]
table = "public.events"
index = "events-{created_month}" # a column holding e.g. 2026-08
And per-tenant isolation, where every tenant is searched, sized and secured on its own:
[sync.events]
table = "public.events"
index = "{tenant}-events"
The rules are the id rules, because a name derived from a
column is the same problem as an id derived from one: the column can change,
and the document is then in the old index.
- Same grammar, same row. Literals plus
{column}placeholders, rendered from the row's raw values — before projections and transforms — exactly asidis. Every placeholder must name a column of the table, andvalidatechecks that against the catalogue. A fanned row's element documents all go where the row goes, so the template may not name thefan_outcolumn. - A rendered name that is not a legal index halts the pipeline. An
uppercase letter, an empty value, a NULL in a named column: none of these
can become an index the target accepts, so the pipeline stops and names
the template, the column and the value it rendered.
validatewarns up front for nullable columns. - Non-key columns need the before-image. A template naming only key
columns works everywhere. One naming a column outside the key needs the
old row to find the index a changed row was in, so on PostgreSQL the table
must be
REPLICA IDENTITY FULL;runrefuses to start otherwise. MySQL already guarantees it (binlog_row_image = FULL). - The index is created on demand, at the first document that needs it,
with the section's
mapping_fileif one is set. Nothing is created at startup, because the set of indices is not known until the rows are. - A template must have a literal part, and may not overlap another
section's index or be shared. Each placeholder stands for
*in what aTRUNCATEclears, soindex = "{tenant}"— a claim on the whole cluster — is refused at config load; so is a template whose pattern also matches an index another section writes to, and a template two sections name. TRUNCATEclears the pattern. Every index the template claims is searched, and each hit is deleted under its own index as a versioned write, so a row committed after the truncate is not swept away with it.- A row that changes its index-choosing column moves. It is written in
the new index and deleted from the old — the same move
idmakes for a row whose id changed. reconcile,switch-aliasandreindexrefuse a templated table. Reconcile pages one index by its key column, and the table's documents are spread over every index the template renders; an alias points at one index, and so does a rebuild.resnapshotworks.- Meilisearch refuses a template at startup: it has no mappings to create an index with.
- Bulk-load settings are not relaxed for a templated index. An index created during the initial load takes the target's defaults.
Rebuilding an index
A mapping cannot be changed on an index that already exists, so changing one
means building a new index and moving the traffic to it. reindex is that in
one command:
pg2osync reindex -c pg2osync.toml --table public.users --alias users
It creates users-<unix seconds> with the section's mapping_file, loads the
table into it, compares what it wrote against the source's row count, and
points the alias at the new index in one atomic request. --drop-old deletes
the index the alias came off; by default it is kept, because it is the
rollback — one alias flip away.
- Stop the pipeline first; the command refuses to run beside it. A
re-snapshot is safe beside the stream because a copied row and a streamed
change meet in the same index and the higher position wins. A fresh index the
stream is not writing to has no second document to compare against, so a row
that changed during the rebuild would be wrong there for good — and the count
would still add up. The refusal is evidence, not a flag: an active
replication slot on PostgreSQL, and on either source a checkpoint seen
moving. There is no
--force. - The checkpoint does not move, by construction: the rows carry position
0, exactly as a re-snapshot's do. So everything committed while the pipeline was stopped is still in the log, and the restart replays it into the new index. That is also what proves the contents: the count only proves how many. - Two follow-ups are yours, and the command prints both: set
indexto the new name in the section, and start the pipeline again. - A count the source does not explain leaves the alias where it is. The source is counted before and after the load; anything outside that range is reported with all three numbers and a non-zero exit, and the rebuilt index is left for you to look at.
- Refused for a templated index, a
shared index or a join pair, a fanned
table, and an
--aliasequal to the index the section already writes to. - On Meilisearch the alias is the index, so
--aliasthere must be the name the section already writes to and every other value is refused. There is no alias namespace on that target; the rebuilt index is swapped into the live name withPOST /swap-indexesinstead, which is atomic in the same way. Two things differ afterwards: no config edit is needed, only the restart, and it is<index>-<unix seconds>that ends up holding the documents from before the rebuild — see the Meilisearch sink. - A live cutover with no freshness gap at all is still two instances, as operations.md describes. A rebuild trades the gap for one command.
Retention
An index per month is only half of time-based retention: something still has to delete August once nothing searches it any more. pg2osync does not, and neither target needs it to — both have an index-lifecycle feature that acts on an index pg2osync created on demand without pg2osync knowing anything about it. The policy is one PUT the operator makes once; the target owns the lifecycle of its own indices, and a sync tool that also deleted them would be a second, weaker copy of a scheduler that is already there.
Elasticsearch: name an ILM policy in the mapping's settings. The
mapping_file is the index-creation body, settings included, and it is sent
verbatim for every index the template renders — so the policy is attached to
events-2026-08 at the moment the first August row creates it, and to
events-2026-09 a month later, with no template to maintain:
{
"settings": {
"index.lifecycle.name": "events-30d"
},
"mappings": {
"properties": {
"created_at": { "type": "date" }
}
}
}
The policy itself is created once, by hand:
PUT _ilm/policy/events-30d
{
"policy": {
"phases": {
"delete": {
"min_age": "30d",
"actions": { "delete": {} }
}
}
}
}
index.lifecycle.name is all that is needed here: min_age counts from the
index's creation date for an index that never rolls over, which is exactly
what a month bucket is. index.lifecycle.rollover_alias belongs to the
rollover action — a write alias moving from one index to the next — and a
row-chosen index has no such alias: the row's own column says where it goes.
Setting it without a rollover action makes the policy fail on an index it
cannot roll over. (See
ILM index settings
and the delete action.)
OpenSearch: match the indices from an ISM policy. ISM attaches itself. A
policy carrying an ism_template is applied to every index created after it
whose name matches one of the template's patterns, so nothing goes in
mapping_file at all:
PUT _plugins/_ism/policies/events-30d
{
"policy": {
"description": "Delete an events index 30 days after it was created.",
"default_state": "hot",
"states": [
{
"name": "hot",
"actions": [],
"transitions": [
{ "state_name": "delete", "conditions": { "min_index_age": "30d" } }
]
},
{
"name": "delete",
"actions": [{ "delete": {} }],
"transitions": []
}
],
"ism_template": [
{ "index_patterns": ["events-*"], "priority": 100 }
]
}
}
The legacy way of attaching a policy — an index template setting named
plugins.index_state_management.policy_id (opendistro. before that) — is
deprecated in favour of ism_template. It still works on the 2.x line, but
it needs an index template to carry the setting, which is the maintenance the
ism_template field removes. (See
Index State Management
and the ISM API.)
Both mechanisms have the same two edges:
- The policy has to exist before the index does. An ISM template is
consulted at index creation only, and an ES index created before the policy
existed carries no
index.lifecycle.name. Indices already in the cluster are attached by hand —POST _plugins/_ism/add/events-2026-07on OpenSearch,PUT /events-2026-07/_settingswithindex.lifecycle.nameon Elasticsearch — and every index created from then on is covered. - Nothing deletes on the stroke of the boundary. ILM checks its indices
every
indices.lifecycle.poll_interval(10 minutes by default), ISM on its own job schedule; an index outlives itsmin_ageby that much.
Keep the pattern narrow enough to miss pg2osync's own state. events-* is
fine; a policy matching * would also claim the hidden .pg2osync_meta
checkpoint index and eventually delete the position the pipeline resumes
from.
Data streams are not supported. A data stream accepts only create
actions in a bulk request, and pg2osync writes index actions: every document
is keyed by its id and rewritten, because at-least-once delivery means a
replayed change has to overwrite the document it already wrote rather than be
rejected or duplicated. That holds even for an
append_only table, whose content hash exists precisely
so that a re-delivered row lands on the same document again. A time-bucketed
index with a lifecycle policy is what a data stream would have given here
anyway: whole indices dropped by age, never documents deleted one at a time.
Fan-out
One row whose array column holds N elements can become N documents:
[sync.tickets]
table = "public.tickets"
id = "ticket-{id}"
[sync.tickets.fan_out]
field = "tags" # a PostgreSQL array column, or jsonb holding an array
id = "ticket-{id}-{tags}"
Each element document is the parent document minus the array, merged with
the element: an object element's fields are merged in and win on collision, a
scalar element lands under the array's own field name. The element id
renders from that merged document, so its placeholders can name parent columns
and element fields alike.
- A row with an empty or missing array emits nothing; a row whose array is
NULL keeps one parent document under the plain
id. - Updates diff before against after: elements that left the array have their
documents deleted, the rest are rewritten. Deletes remove every element
document the row owned. All of it as ordinary versioned writes, in the same
order as everything else —
write_concurrencykeeps working. - PostgreSQL: the table needs
REPLICA IDENTITY FULL(checked at startup), because deletes and diffs come from the row's old values. Poll mode and[[children]]on the same table are refused; so is naming the fan-out column incolumns/exclude_columns, which would cut the array before identity and fan-out ever see it.reconcileandresnapshotdo not support fanned tables yet: both page by key, and one row now has many documents.reindexrefuses one too — it checks what it wrote against the source's row count, and here one row is many documents.
Two tables may map to the same index once each declares its id; see
Sharing an index.
Transforms
A column can be reshaped on its way into the document. transform maps a
source column to one of six named operations: a string for an op that takes
no parameter, an inline table for one that does.
[sync.users]
table = "public.users"
[sync.users.transform]
email = "hash"
phone = "redact"
payload = "json" # or { op = "json" }
price = "number"
tags = { op = "split", by = "," }
born = { op = "date", from = "%d/%m/%Y" }
hash replaces the value with a truncated SHA-256 digest, stable across runs so
it can still be grouped on. redact replaces it with ***. The other four turn
a string into something more structured:
| op | takes | turns | into |
|---|---|---|---|
hash | — | any value | a truncated SHA-256 digest |
redact | — | any value | *** |
json | — | a string holding JSON | that JSON value, an object or a bare number alike |
split | by, required and non-empty | a delimited string | an array of its trimmed, non-empty pieces: "a, b ,c" → ["a","b","c"], "" → [] |
number | — | a string holding a number | a JSON number: an integer when it is one, otherwise a double |
date | from, a strptime-style format, required and non-empty | a string in that format | ISO 8601: YYYY-MM-DD for a date, YYYY-MM-DDTHH:MM:SS for a date-time, RFC 3339 with the offset kept when the format carries one |
NULL is left alone by every op, and so is a value already in the target shape:
a parsed json/jsonb/JSON column under json, an array under split, a
number under number. That is what keeps the ops idempotent when
at-least-once delivery replays a row, and it is why the three exist for
text columns that hold something more structured. number is also the
explicit opt-out of the rule that numeric/DECIMAL arrive as strings to
keep their precision — for an index that sorts or range-queries on the value
and accepts the double.
A value an op cannot convert — "abc" under number, a date that does not
match from — is indexed exactly as it arrived, counted in
pg2osync_transform_unconverted_total, and logged once per table and column.
The pipeline never halts on it: the target's mapping is the arbiter of what a
field holds, and a document the mapping refuses takes the ordinary rejection
path (see on_permanent_rejection). A fanned row counts once per element
document.
- If one field will hold both converted and unconverted values,
mapping_fileshould type it astext. Otherwise dynamic mapping types the field from the first document and refuses the second — and that refusal is the halt or quarantine path, not this policy. - Transforms name the source column and run after projection, before
fieldsrenames andconstants. splitcannot feedfan_out: fan-out reads the raw row, before any transform, so it needs a real array column.
Refused at load: an unknown op, a parameter the op does not take, split
without a non-empty by, date without a non-empty from, and a transform
on the fan_out.field.
Field names
An index that already exists is rarely named after the database. fields
stores a column under another name:
[sync.users]
table = "public.users"
[sync.users.fields]
usr_nm = "username"
The rename is the last shaping step — identity, fan-out, projection and
transforms all run first — so every other option (columns,
exclude_columns, transform, id, fan_out.field, primary_key,
soft_delete, poll_column) keeps naming the column as the source knows it.
The new name applies on the initial load, the stream, poll mode and a
re-snapshot alike, and inside embedded child arrays through the child's own
fields (see Nested children).
Refused at load: an empty name, renaming a column to itself, two columns to
the same name, renaming an excluded column or one missing from columns, a
target that equals a non-renamed column in columns, and a parent rename that
names or targets a child field (or its _truncated/_total, which a
single child does not write and so does not claim). validate
warns when a renamed column does not exist — a stale config, as with
exclude_columns — and refuses a target that equals a live column that is not
itself renamed away.
- TOAST completion reads the stored document, so it finds the column under its new name.
mapping_filemust declare the renamed names: the mapping is compared against the index, never against the table.
Constant fields
A tag several indices can be queried by, or a marker of where a document came
from, needs no column. constants adds a literal value to every document of
the section:
[sync.users]
table = "public.users"
[sync.users.constants]
entity = "user"
tenant = "eu"
origin = "{schema}.{table}"
rank = 3
active = true
Scalars only — string, integer, float, boolean; arrays, tables and datetimes
are refused at load. {schema} and {table} are the only placeholders,
allowed only inside a string and rendered once at startup, so a string
naming any other placeholder (or with a malformed {) is refused at load. A
string without { is taken verbatim; there is no way to write a literal {.
Constants are added last — after identity, fan-out, projection, transforms
and renames — because columns would otherwise strip a field that is not a
column. Every fanned element document carries them; child arrays do not.
Refused at load: a name that is a rename target, a surviving entry of
columns (one not itself renamed away), a child field (or its
_truncated/_total, which a single child does not claim), or the
fan_out.field. validate additionally
refuses a name that equals a live column the projection keeps. A name equal
to a rename key is fine: that column leaves the document first. At write
time the constant wins.
mapping_fileis compared for containment, so a constant it does not name gets whatever dynamic mapping infers from the first document; declare it there if the type matters.
Row filters
Not every row of a table belongs in the index. where is a predicate in a
restricted SQL subset that decides which rows do:
[sync.users]
table = "public.users"
where = "status = 'active' AND tenant IN ('eu', 'us') AND deleted_at IS NULL"
| form | example |
|---|---|
| comparison, the column always on the left | status = 'active', tier <> 'free' (or !=), price > 10, <, <=, >= |
| null test | deleted_at IS NULL, parent_id IS NOT NULL |
| membership | tenant IN ('eu', 'us'), kind NOT IN (1, 2) |
| connectives | AND, OR, NOT, parentheses |
| literals | 'text' ('' for a quote), integers, decimals, true/false |
Keywords are case-insensitive. There are no functions, no LIKE and no
column-to-column comparison; anything outside the subset is refused at config
load with a message listing what is supported.
The initial load pushes the predicate into its query — the COPY on PostgreSQL,
the chunk reads on MySQL — so a row that does not match is never read, shipped
or indexed; resnapshot --where ANDs with it, and reconcile treats a row
that no longer matches as gone. The engine then evaluates the same predicate
on every streamed and polled row: one whose new state matches is written, one
whose new state does not is deleted from the index — every element document of
a fanned row, the id a moved row used to own. That is what makes a row that
leaves the filter disappear and one that enters it appear. The predicate sees
the raw row, before projection, so a column that columns excludes can
still be filtered on.
- NULL follows SQL: a comparison against NULL is unknown,
NOTof unknown is unknown, and a row matches only when the predicate is TRUE.IS NULLalso matches a column the source did not send. - Strings compare byte-wise. Equality is exact everywhere; ordering is exact
for ASCII and ISO 8601, which is what makes
created_at >= '2024-01-01'work against the textual timestamps the sources hand over. - A number compared against a string holding a number compares numerically:
numeric/DECIMALreach the engine as strings to keep their precision, and SQL would compare them as numbers, soprice > 10matches10.01. - Nothing new is asked of the source: a key-only id renders its delete from
the key, and non-key ids and
fan_outalready required the before-image. validaterefuses a predicate naming a column the table does not have, and runsSELECT 1 FROM t WHERE (predicate) LIMIT 0against the live table to catch what the grammar cannot, such as a type error.- Poll mode does not push the predicate into its query, on purpose: a row that has left the filter must keep arriving so the engine can turn it into the delete it now is. See Soft deletes for how the two compose.
- The cost, stated plainly: a WAL insert of a row that never matched still produces one idempotent delete, which the target answers not-found, and a non-matching parent of a child collection produces one such delete per child change.
- A filter selects rows; it computes no values. There is still no transformation language.
Soft deletes
Poll mode has no replication log, so a row that is simply gone leaves nothing to poll and cannot be seen. A row marked deleted can be:
[sync.users]
table = "public.users"
soft_delete = "deleted_at IS NOT NULL"
A row matching the predicate is removed from the index instead of upserted, and
the initial load skips it rather than indexing it only to delete it on the
first cycle. The predicate is evaluated by the database — poll mode has a query
to put it in — so any boolean expression over the row's own columns works,
status = 'archived' as much as a timestamp check.
It is poll-mode only, and configuring it elsewhere is rejected rather than
ignored. The general form is a row filter:
where = "deleted_at IS NULL" works in WAL, binlog and poll mode alike, and
turns the UPDATE that marks a row deleted into the delete it means. What
soft_delete keeps for poll mode is the database's evaluation, and with it
any expression the grammar of where does not accept. The two compose —
soft_delete deletes, where gates — and naming the same column in both is
redundant rather than wrong.
Index mappings
Without mapping_file the index is created empty and the target infers field
types from the first document that carries each field. That is enough to get
started and not enough for real search: analyzers, keyword subfields for
aggregation, explicit date formats and vector fields all have to exist before
the first document lands.
[sync.users]
table = "public.users"
index = "users"
mapping_file = "users-mapping.json"
The path is resolved relative to the config file, and the file is read at startup so a missing or malformed one fails before anything connects. It holds either a full index-creation body or just the mapping:
{
"mappings": {
"properties": {
"id": { "type": "long" },
"name": { "type": "text", "fields": { "raw": { "type": "keyword" } } },
"created_at": { "type": "date" }
}
},
"settings": { "number_of_shards": 1 }
}
Three rules, and the reasoning behind each:
- It applies only when the index does not exist. A target refuses to change an existing field's type — that is a reindex — so applying a mapping to a live index would either fail or quietly do half the job.
- An existing index is compared against it at startup. A field the index maps to a different type is an error: every document carrying it would be rejected, and a permanent rejection halts the pipeline. A field the index does not declare is a warning: it will be mapped from whatever value arrives first, which may be what you wanted.
- Only the fields you name are checked. The target normalises what it is given and dynamic mapping legitimately adds fields you never declared, so an equality check would report differences on a mapping that is exactly right.
If you would rather manage an index template, leave mapping_file unset: the
index is then created without a body and your template applies. Configuring
both means the creation body wins and the template is ignored for these
indices.
Meilisearch has no field types to declare; mapping_file is refused for that
target rather than ignored.
Ingest pipelines
A vector field is the one thing a mapping can declare that no row can fill:
the embedding has to be computed by something that holds the model. pg2osync
does not; the target does. pipeline names an ingest pipeline on the target,
and every document the section writes carries it on its bulk action, so the
target runs the pipeline's processors on the way in:
[sync.products]
table = "public.products"
index = "products"
mapping_file = "products-mapping.json"
pipeline = "embed-products"
The pipeline is yours to create, before the first document lands. A
text_embedding processor (OpenSearch's neural-search plugin; Elasticsearch
has the inference processor) reads a text field and writes the vector into a
field the mapping declares as knn_vector:
{
"mappings": {
"properties": {
"name": { "type": "text" },
"description": { "type": "text" },
"embedding": { "type": "knn_vector", "dimension": 384 }
}
},
"settings": { "index.knn": true }
}
pipeline = "embed-products" is the whole of pg2osync's part; the model, the
processor and the dimension above belong to the pipeline and the mapping,
and a set processor or any other works the same way.
validate asks the target for the pipeline (GET _ingest/pipeline/<name>)
and refuses a name it does not have, because every document would otherwise
be rejected at the first write, with the pipeline named but the config already
running. A pipeline that exists is reported by name, one line per section.
Three things follow from the pipeline riding on the operation rather than on the index:
- It is per section, not per index. Two tables feeding one index (see
Sharing an index) may name different pipelines, so each
embeds its own columns; a section without
pipelinewrites to the same index with none. - A delete carries no pipeline. Ingest pipelines run on index actions only, which is also what the target does.
- A quarantined document is replayed through the pipeline again. The
record kept by
on_permanent_rejection = "quarantine"stores the pipeline with the operation, sopg2osync rejects --replaysubmits it the way it was first submitted, and the document does not land without its vector.
Meilisearch has no ingest pipelines; pipeline is refused for that target at
config load rather than ignored.
Nested children
Embed a one-to-many relation as a JSON array on the parent document:
[sync.customers]
table = "public.customers"
index = "customers"
primary_key = "id"
[[sync.customers.children]]
table = "public.orders" # child table
field = "orders" # array field on the parent document
foreign_key = "customer_id" # column on the CHILD referencing the parent key
# max_rows = 1000 # optional: embed at most this many, see below
# single = true # optional: a 1:1 relation, see below
A child's columns are renamed the same way, on the child element rather than on the parent:
[[sync.customers.children]]
table = "public.orders"
field = "orders"
foreign_key = "customer_id"
[sync.customers.children.fields]
total = "amount" # every element of `orders` carries `amount`
<field>_truncated and <field>_total follow the child field, not a rename.
A child collection is projected the same way as a section, with columns or
exclude_columns on the child rather than on the parent:
[[sync.customers.children]]
table = "public.orders"
field = "orders"
foreign_key = "customer_id"
exclude_columns = ["internal_notes"] # every element leaves this column out
# columns = ["id", "total"] # or list what to keep — not both
The two are mutually exclusive, as on a section, and an empty columns list is
refused. The projection happens in the read: the initial load and the
per-transaction re-fetch are built from the same expression, so they cannot
embed different shapes, and PostgreSQL never reads a column the element does not
name. fields runs after the projection and names the source column, so a
column that is excluded — or left out of columns — cannot also be renamed;
that is refused at startup rather than silently dropping the rename. The
foreign_key is kept only if you list it: it is read as its own column beside
the element, so leaving it out of the array changes nothing but the array.
- PostgreSQL and MySQL/MariaDB alike.
- One level deep only.
- Children are fetched during the initial load and re-fetched whenever the parent or any of its children changes, so the array is never stale.
- Child tables are added to the publication automatically.
- The initial load reads each collection once and joins it, so it costs one query per table no matter how many parents there are.
- Streamed changes cost one query per collection per transaction, not per row. Rows are held until the transaction commits, the distinct parents they affect are collected, and each collection is read once for the whole group — so a transaction touching 2,000 children of 20 parents issues 3 queries and writes 20 documents, where per-row resolution issued 4,001 and wrote 2,000. Index the foreign key on the child table: those lookups compare the key in its own type, and without an index each one scans the whole child table.
- The array is ordered by the child table's primary key, so the initial load and a later re-fetch embed it identically and a re-snapshot does not rewrite documents for no reason. A child table with no primary key has no such order, and says so at startup.
How many children to embed
max_rows is unset by default, so the whole collection is embedded however large
it is. That is deliberate: a cap loses data, and the bound that matters is already
the target's. Past index.mapping.nested_objects.limit — 10,000 by default —
OpenSearch refuses a document whose field is mapped nested, because every
element becomes a hidden Lucene sub-document; that refusal names the parent and is
quarantined rather than lost (see on_permanent_rejection). Below the limit the
cost is gradual. An array past 10,000 is logged, naming the parent, so the
decision is visible rather than a surprise later.
Setting max_rows trades a complete array for a bounded document. A document
whose array was cut says so, in two extra fields:
{ "id": 42, "orders": [ /* max_rows of them */ ],
"orders_truncated": true, "orders_total": 100000 }
They appear only when something was left out, so orders_truncated: true finds
every affected parent in one query. Which rows are kept is decided by the child
table's primary key, so the same ones are kept every time and the initial load and
a streamed re-fetch agree — a cap without that order would keep a different subset
on each run. max_rows on a child table with no primary key is refused at
startup for the same reason.
- The field name must not collide with a column of the parent table; the initial load refuses to start rather than shadow a real column.
- Give child tables
REPLICA IDENTITY FULL(ALTER TABLE public.orders REPLICA IDENTITY FULL). Without it a DELETE carries no foreign key, so the parent cannot be located; pg2osync warns at startup and fails on such a delete rather than silently going stale. - MySQL/MariaDB: the child table is streamed from the binlog automatically;
binlog_row_image = FULL(already required) is what lets a child DELETE carry its foreign key, so there is no REPLICA IDENTITY caveat.
A one-to-one relation
A users → profiles relation is one child, and an array of one makes every
query and every mapping carry an index that is always zero. single = true
embeds the element itself:
[[sync.customers.children]]
table = "public.profiles"
field = "profile"
foreign_key = "customer_id"
single = true
{ "id": 42, "profile": { "customer_id": 42, "bio": "..." } }
The field is always present: a parent with no child gets "profile": null, so a
query need not know whether this parent happens to have one. fields, columns
and exclude_columns work exactly as they do on an array child.
Map the field as object, not nested. nested exists to keep the
elements of an array from being flattened into one another; there is no array
here, so it buys nothing and index.mapping.nested_objects.limit — the whole
reason the array form has a cap — does not apply.
max_rowsis refused withsingle: a relation declared one-to-one has nothing to cap.<field>_truncatedand<field>_totalare never written, and the two names are free for a column, a constant or a rename.- The child table needs a primary key, refused at startup otherwise: with no order there is no first row, so two runs could embed different ones.
- A second matching row does not fail the run — a duplicate that exists for
the length of a migration must not halt an index. The lowest-keyed row is
embedded, which is the same one a re-snapshot picks, and each batch logs one
warning naming the collection, how many parents matched twice and the worst of
them. Fix the data, or drop
single, and the next change to those parents rewrites them.
Join fields
The embedded array above is one document and one write, and it is the right
choice nearly always. When the children are many, change far more often than
the parent, or have to be searched in their own right, a join field keeps each
child a document of its own — on its parent's shard, so has_child and
has_parent queries work — instead of re-fetching the whole collection on
every child change:
[sync.customers]
table = "public.customers"
index = "shop"
id = "customer-{id}"
mapping_file = "shop-mapping.json"
[sync.customers.join]
field = "relation" # the join field the mapping declares
name = "customer" # this table's relation name inside it
[sync.orders]
table = "public.orders"
index = "shop"
id = "order-{id}"
[sync.orders.join]
field = "relation"
name = "order"
parent = "customer_id" # column on THIS table holding the parent's key
parent is what makes a section the child: it names the column whose value,
rendered through the parent section's id, is the parent document's id — and
the child's routing. The parent omits it. Each document carries the join field
in the shape the target expects, "customer" on a parent and
{"name": "order", "parent": "customer-1"} on a child. It is written after
projection and renames, like a constant, so nothing can strip it or move a
document to another shard. OpenSearch and Elasticsearch only.
-
The mapping lives on the parent, and only there. A join pair is two sections and one index; the parent's
mapping_filecreates it and has to declare the field:{ "mappings": { "properties": { "relation": { "type": "join", "relations": { "customer": ["order"] } } } } }A child that sets
mapping_fileis refused. Dynamic mapping cannot invent a join field, so without this mapping — or an index template that declares the field — the first document is rejected. -
Ids must be unique across the shared index. Parent
customers.id = 1and childorders.id = 1would both render_id = "1", and the child would overwrite the parent. Configuration cannot see it; give each section anidwith its own prefix, ascustomer-{id}andorder-{id}above. -
The parent's
idmay name only its key. The child holds one column and has to compute the parent's id from it alone, so an id naming anything else is refused at load, and a parent with a composite key at startup. -
PostgreSQL: the child needs
REPLICA IDENTITY FULLunless its parent column is part of its own key. A delete has to reach the shard that holds the document, and the routing comes from the old row — the same rule a non-keyidfollows;runrefuses to start otherwise. A child whose parent column changes moves: written under the new parent, deleted under the old. MySQL already guarantees the before-image. -
A parent delete cascades. The engine does not know which children the target holds, so the sink refreshes the index and searches for them, after the parent's own delete and at the parent's position. The refresh is what makes it correct — a child written seconds earlier would otherwise survive — and it is the cost: one per deleted parent, which the batch waits for. Each batch that carries one counts a
join_cascadeevent inpg2osync_events_total. -
TRUNCATEon either table clears its relation only. The join field tells the halves apart, so a truncate of the orders removes everyorderdocument — routed to its parent's shard — and leaves the customers, unlike a plain shared index, where nothing can tell one table's documents from another's. PostgreSQL makes you truncate tables that reference each other together anyway, so usually both. -
A
wherefilter is the operator's to keep consistent across the pair. A child whose parent the parent section'swherefilters out is indexed with aparentthat no document has. -
A NULL in the parent column halts the pipeline, as a NULL in an
idcolumn does;validatewarns when the column is nullable, and refuses a column the table does not have. -
reconcilescopes its scan to one relation, so it checks either half of the pair against its own table and routes the deletes it makes.
Refused at config load: two sections on one index without join on every one
of them and without an id on every one of them (see
Sharing an index); sections of one index naming
different join fields; an index with no
parent, or with two; two sections sharing a relation name; a child that only
names a parent no other section provides; a child with mapping_file;
fan_out together with join; a parent id naming a column outside its key;
join against Meilisearch, which has no parent-child model; a fields
rename, a constant, a columns entry or a [[children]] field that collides
with the join field. [[children]] on a join child is allowed: embedding an
array on this document and filing this document under a parent are unrelated.
A table that is both an embedded child of another section and a section of
its own is warned about at startup, not refused: the replication runner reads
its rows only as a re-fetch of the owner, so its own index receives the initial
load and no streamed change.
Routing
A document's shard is chosen from its _id unless something says otherwise.
routing says otherwise: it names a column whose value decides the shard, so
every document sharing that value lands on one shard and a query for it reads
one shard instead of all of them.
[sync.documents]
table = "public.documents"
index = "documents"
routing = "tenant_id"
A tenant column is the usual case: hundreds of small tenants in one index, each query scoped to one of them. An index per tenant would be the alternative, and it is the wrong one when tenants are many and small — every index costs shards, and shards cost memory whether they hold ten documents or ten million.
- The value is the column's raw value, read before projection and
transforms, like an
idis: a projection must not be able to move a document to another shard. The column stays an ordinary field of the document; routing adds nothing to it. - NULL, missing, or empty halts the pipeline. The target rejects an empty
routing outright, and quietly writing the document to its default shard
would hide it from every routed query.
validaterefuses a column the table does not have and warns when it is nullable. - PostgreSQL: a routing column outside the key needs
REPLICA IDENTITY FULL. A delete has to reach the shard that holds the document, and the old value comes from the before-image — the same rule a non-keyidfollows;runrefuses to start otherwise. MySQL already guarantees the before-image withbinlog_row_image = FULL. - A changed value moves the document: written under the new routing
first, deleted under the old second, exactly as a changed
idor a changed index template moves it. - Fanned-out elements inherit the row's routing, and a row that changes its routing takes them all with it.
- A routing column that is both projected away and TOASTable halts on an
update that does not resend it, like a non-key
idcolumn: the read-back fills the document, not the row the routing renders from. reconcileandTRUNCATEare unaffected. Both work index-wide and take each document's routing from the hit itself, so neither has to derive one. A document duplicated under a stale routing is not something reconcile collects: the row it belongs to is still there.- Poll mode leaves the old copy. A poll cycle sees the row's new state
and nothing else, so a changed routing value writes a second copy under the
new routing and never deletes the first — the same limitation a changed
idhas in poll mode.
Refused at config load: routing together with join, which already routes
a child to its parent's shard, and routing against Meilisearch, which
ignores routing entirely.
[engine]
Defaults are production-sane; tune only against measurements.
| Option | Default | Description |
|---|---|---|
batch_size | 500 | Rows per sink request |
batch_max_bytes | 10485760 | Approximate byte ceiling per request; whichever limit hits first splits the batch |
write_concurrency | 1 | Write requests open against the target at once. One at a time is what the initial load is limited by, not the source read; raising it multiplies the load on the target, and it needs a target that orders by document version, so Meilisearch refuses anything above 1 |
txn_buffer_cap_mb | 256 | Warning threshold for one open transaction |
retry_max | 10 | Attempts per request before the pipeline stops |
retry_backoff_ms | 500 | Initial backoff, doubled per attempt, capped at 30 s |
checkpoint_interval_ms | 500 | How often the position is persisted |
on_permanent_rejection | "halt" | "halt" stops the pipeline on a document the target will never accept. "quarantine" records it in a hidden .pg2osync_rejects index, with its position, and carries on |
max_rejects | 100 | Quarantined documents allowed before the pipeline halts anyway. Counted against what the store holds, so a restart does not reset it |
checkpoint_interval_ms is the ceiling on replayed work after a crash: a lower
value means less replay and more writes to the target.
A transaction larger than txn_buffer_cap_mb is split across requests, which
means the target briefly holds part of it. Everything is idempotent, so the end
state is correct, but a reader can observe the transaction half-applied.
Transient failures (HTTP 429, 5xx, connection resets) are retried with
exponential backoff. A permanent rejection — a mapping conflict, for example —
stops the pipeline instead of skipping the document, because skipping is silent
data loss. on_permanent_rejection = "quarantine" trades that for availability:
the document is recorded with its position before the position is acknowledged,
so nothing is lost, but the transaction it belonged to is applied without it.
pg2osync rejects --replay puts it back once the mapping is fixed. Only the
OpenSearch and Elasticsearch targets can quarantine; configuring it against
Meilisearch fails at startup.
[api]
The read-your-writes endpoint. Off by default: it is a surface applications call, not an operational one.
| Option | Default | Description |
|---|---|---|
enabled | false | Serve the endpoint |
bind | 127.0.0.1:9101 | Listen address |
token_env | — | Env var holding a bearer token required on every request |
GET /synced
Blocks until everything committed before the request is written to the target, then answers. A query made after it returns is guaranteed to see those writes.
| Parameter | Default | Description |
|---|---|---|
position | read from the source | Where to wait for; omit and pg2osync reads it itself |
timeout | 5000 | Milliseconds to wait, capped at 30 s |
refresh | false | Also make the writes searchable, not merely stored |
GET /synced?refresh=true&timeout=2000
200 {"synced":true,"requested":"0/1B4F2A8","confirmed":"0/1B4F2B0","waited_ms":5}
408 {"synced":false,…} still behind when the timeout elapsed
400 the position could not be parsed
Leave position out unless you have a reason not to. Reading it requires
REPLICATION CLIENT on MySQL — a privilege an application account should not
hold — and pg2osync already has a connection that does.
refresh=true is what separates stored from searchable: OpenSearch and
Elasticsearch only expose a write to search after a refresh, on their own
interval. Without it the document is retrievable by id but a search may not
find it yet.
The wait costs nothing on the write path. A background job that does not care never calls this and pays nothing.
[metrics]
| Option | Default | Description |
|---|---|---|
enabled | true | Serve the Prometheus endpoint |
bind | 127.0.0.1:9100 | Listen address; use 0.0.0.0:9100 in a container |
token_env | unset | Variable holding a bearer token required on /metrics |
Only GET /metrics and GET /healthz are served; anything else is a 404.
/healthz is never authenticated, because a kubelet probe has nowhere to keep
a token and a liveness check that fails on a missing one would restart a
healthy pipeline.
With token_env set, Prometheus sends the same token:
scrape_configs:
- job_name: pg2osync
authorization:
type: Bearer
credentials_file: /etc/prometheus/pg2osync-token
static_configs:
- targets: ["pg2osync:9100"]
pg2osync_events_total{type="row|truncate|join_cascade"} # join_cascade: a batch that removed a deleted parent's children
pg2osync_batches_flushed
pg2osync_toast_readbacks_total # reads to complete TOASTed columns
pg2osync_sink_errors_total
pg2osync_rejected_total # documents the target refused, quarantined instead of written
pg2osync_transform_unconverted_total # values a transform could not convert, indexed as they were
pg2osync_schema_drift_total{table="schema.table"} # a table changed shape; the index keeps the old one until rebuilt
pg2osync_reconnects_total
pg2osync_source_connected # 1 while the source is streaming, 0 while reconnecting
pg2osync_latency_ms{quantile="0.5|0.9|0.99"} # source commit to indexed
pg2osync_latency_ms_count
pg2osync_position_current # highest position received
pg2osync_position_confirmed # highest position checkpointed
pg2osync_position_lag # difference between the two
Environment variables
| Variable | Purpose |
|---|---|
RUST_LOG | Log filter, e.g. pg2osync=debug |
PG2OSYNC_LOG_FORMAT | text (default) or json for one JSON object per line |
PG2OSYNC_INSTANCE_ID | Recorded in the checkpoint document; identifies the writer |
whatever *_env names | The credentials themselves |
Complete example
[source]
flavor = "postgres"
url_env = "PG2OSYNC_SOURCE_URL"
slot_name = "pg2osync"
publication = "pg2osync_pub"
[target]
flavor = "opensearch"
url = "https://opensearch.internal:9200"
username = "pg2osync"
password_env = "PG2OSYNC_TARGET_PASSWORD"
tls_verify = true
[engine]
batch_size = 500
batch_max_bytes = 10485760
write_concurrency = 1
checkpoint_interval_ms = 500
[metrics]
enabled = true
bind = "127.0.0.1:9100"
[sync.users]
table = "public.users"
index = "users"
exclude_columns = ["password_hash"]
where = "deleted_at IS NULL"
[sync.users.transform]
email = "redact"
interests = { op = "split", by = "," }
[sync.customers]
table = "public.customers"
index = "customers"
primary_key = "id"
[[sync.customers.children]]
table = "public.orders"
field = "orders"
foreign_key = "customer_id"