Companion to: Getting Started. Read this when you’ve noticed that
datom_history()shows aversioncolumn and adata_shacolumn and want to know why there is more than one hash – or whendatom_write()has told you a column is not hashable.
datom identifies a table with three SHAs, each answering a different question:
| SHA | Question it answers | Computed over |
|---|---|---|
data_sha |
Is this the same table content? | the table’s values, canonically encoded
(datom-cv1) |
metadata_sha |
Is this the same version? | the table’s semantic metadata, as canonical JSON |
parquet_sha |
Are the stored bytes intact? | the exact parquet object that was uploaded |
A fourth hash, original_file_sha, is recorded for tables
onboarded from a file by datom_sync(). It is not part of
table identity – it is file provenance, and it is what lets a
re-scan say “this input has not changed” without reading the file.
The rest of this article explains each one, the type contract
data_sha depends on, and the identity decisions baked into
it.
data_sha: the canonical content hashdata_sha is a hash of the table’s
values, computed with the datom-cv1
algorithm. Sketching the layout:
# per column, in table column order
col_digest <- sha256(utf8(type_tag) || utf8(column_name) || 0x00 || payload)
# then the table
data_sha <- sha256("datom-cv1" || f64le(nrow) || f64le(ncol) ||
concat(col_digest_hex ...))Two properties matter more than the layout:
data_sha is
computed from the in-memory data frame. It never writes a file, never
calls arrow, and never coerces the container.tibble, a data.table, and a
data.frame holding the same values hash the same.
Attributes that are not values (row names, tbl_df class,
arbitrary attr()s) do not participate.An earlier design hashed the serialized parquet file. That is simpler, and wrong for a versioning tool: parquet bytes move with the writer, not the data. An arrow upgrade, a different compression default, or a different dictionary-encoding heuristic all produce different bytes for identical content – and every one of those would have minted a spurious new version.
Hashing values instead of bytes decouples identity from the
serializer. The serializer’s bytes are still hashed, but under a
different name and for a different purpose: that is
parquet_sha, below.
Hashing values means datom must decide, per column type, what the
bytes of that value are. That decision cannot be guessed, so
datom-cv1 supports a defined set of types and refuses
everything else with specific advice.
Supported: logical, integer,
double, character, factor,
Date (including data.table::IDate),
POSIXct, difftime/hms,
data.table::ITime, bit64::integer64, and
labelled vectors (haven_labelled, labelled,
labelled_spss) over any of those.
Most clinical and analytical tables are already inside the contract. The cases that are not tend to arrive the same handful of ways:
tidyr::nest() result carries a list
column of data frames.strptime() returns
POSIXlt, not POSIXct (which is a list under
the hood, and hashing a list of broken-down time parts is not the same
question as hashing an instant).sf geometry columns are
sfc lists; units columns wrap
a numeric with a unit attribute.None of these are errors in your data. They are values whose canonical byte form is a modelling decision, and datom asks you to make it explicitly rather than picking one silently.
datom_check_hashable() is the pre-flight entry point. It
is pure – no connection, no store, no network – so you can run it on any
data frame:
library(datom)
clean <- data.frame(
id = 1:3,
score = c(1.5, 2.5, 3.5),
grp = factor(c("x", "y", "x")),
day = as.Date(c("2026-01-01", "2026-01-02", "2026-01-03"))
)
datom_check_hashable(clean)
#> ✔ All 4 columns are hashable. This table is ready for `datom_write()`.messy <- data.frame(id = 1:2)
messy$notes <- list(c("a", "b"), "c")
messy$z <- c(1 + 2i, 3 + 4i)
report <- datom_check_hashable(messy)
#> ✖ 2 of 3 columns are not hashable.
#> ✖ Column notes (<list>): List and blob columns are not hashable. Flatten to one
#> value per row with tidyr::unnest(), or serialize each element to character
#> (for example with jsonlite::toJSON() per element), before writing.
#> ✖ Column z (<complex>): Complex columns are not hashable. Split into separate
#> real and imaginary numeric columns, or convert to character, before writing.
#> ℹ Fix these before `datom_write()`, which refuses the whole table until they are resolved.
report[report$status == "unsupported", c("column", "class")]
#> column class
#> 2 notes list
#> 3 z complexThe report is returned invisibly as a data frame, so it composes:
filter it, kable() it, or assert on it in a pipeline.
datom_write() runs the same check itself,
before it touches git or storage, and reports every
offending column in one error rather than one per attempt. A refusal
leaves no partial state behind.
Each refused type has one canonical piece of advice. The table below
is generated by running datom_check_hashable() over one
fixture per case, so it is the same text datom_write()
aborts with – the documentation cannot drift from the code.
| Column class | Recourse |
|---|---|
| POSIXlt/POSIXt | POSIXlt columns are not hashable. Convert to POSIXct with as.POSIXct() before writing. |
| sfc | sf geometry (sfc) columns are not hashable. Convert to WKT text with sf::st_as_text() before writing. |
| list | Nested data-frame (list) columns are not hashable. Model the inner table as its own datom table joined by a key with datom_parent() lineage, or flatten it with tidyr::unnest(), before writing. |
| list | List and blob columns are not hashable. Flatten to one value per row with tidyr::unnest(), or serialize each element to character (for example with jsonlite::toJSON() per element), before writing. |
| units | units columns are not hashable. Drop the unit with units::drop_units() and record the unit in the column name or a companion column (audit-friendly), before writing. |
| yearmon | zoo::yearmon / yearqtr and chron columns are not hashable. Convert to Date/POSIXct or ISO-8601 text before writing. |
| complex | Complex columns are not hashable. Split into separate real and imaginary numeric columns, or convert to character, before writing. |
| raw | Raw columns are not hashable. Encode the bytes as character (for example base64) before writing. |
| myclass | Columns of this class are not hashable. Convert to a supported type (logical, integer, double, character, factor, Date, POSIXct, difftime/hms, or bit64::integer64) before writing. |
datom_sync() onboards flat tabular files
only: csv, tsv, txt,
psv, parquet, sas7bdat,
xpt, sav, zsav, por,
dta, xls, xlsx. Anything else –
.rds, .json, .xml – is refused up
front. datom_sync_manifest() flags it as
unsupported_format and still processes its allowlisted
siblings; datom_sync() reports the same recourse in the
error column for that row.
The reason is the table contract: a container format can deserialize to anything, including the list and exotic columns the hash refuses. Refusing at the door keeps the failure legible (“this format is not onboarded”) instead of deferring it to a type error deep in the write.
The escape hatch, and its tradeoff. You can always
read an unsupported source yourself and pass the resulting data frame to
datom_write():
The rule to know: that table is derived, not
imported. It has no original_file_sha, so input-file change
detection does not apply to it. A re-run will not report “unchanged”
from the file’s bytes; it compares the data. If you need file-level
change detection for that source, convert it to CSV or parquet once and
let datom_sync() onboard it.
Every entry below is a deliberate choice about what counts as “the same table”.
| Decision | Rationale |
|---|---|
logical, integer, double
unify (same encoding, same tag) |
1L and 1 are the same number.
Round-tripping a table through CSV, parquet, or a database routinely
flips storage type without changing the data. |
| Factor levels and orderedness are not identity | A factor’s values are hashed as character. Re-leveling for a plot, or dropping unused levels, must not mint a new data version. |
| Value labels are not identity | haven_labelled columns strip to their underlying type.
A relabelled SAS import carries the same numbers. |
POSIXct tzone is not identity |
The value hashed is the instant (epoch seconds). tzone
is a display attribute; the same instant printed in two zones is one
instant. |
All NaN payloads collapse to one canonical
NaN; -0 encodes as +0 |
0/0 and NaN are indistinguishable
arithmetically, and IEEE-754 leaves NaN payload bits and signed zero to
the platform. Letting them differ would make identity
platform-dependent. |
NA_real_ stays distinct from NaN |
“Missing” and “not a number” are different statements about the data, and R keeps them apart. |
| Doubles are hashed bit-exact – no rounding, no tolerance | A tolerance would make identity non-transitive (a ~ b and b ~ c but a != c). If a pipeline produces last-bit differences, that is a fact about the pipeline; round explicitly if you want it hidden. |
| Unicode NFC and NFD forms differ | Normalizing would require an ICU dependency and a normalization-form decision baked into every future version. Byte-identical text is the narrow, stable promise. |
| Row order and column order are significant; nothing is sorted | Sorting to make identity order-independent needs a collation order,
which is locale-dependent – reintroducing the platform dependence the
rest of this design removes. A reordered table is a different table;
arrange() deliberately if order should not matter. |
On the apparent asymmetry: NaN payloads
are canonicalized while NFD text is not, and storage types unify while
row order does not. The line is drawn at platform
non-determinism, not at “things a human would call equivalent”.
Where the same logical value can have different bytes for reasons
outside the user’s control (NaN payload bits, signed zero,
integer-vs-double storage after a round-trip), datom canonicalizes –
otherwise identity would depend on the machine. Where a difference is a
real, visible, user-controlled property of the data (row order, NFC vs
NFD, secs vs mins), datom preserves it: collapsing those would need a
policy decision that belongs to the user, and any policy datom picked
would be wrong for someone.
metadata_sha: the versionmetadata_sha is what datom_history() calls
version and what you pass to
datom_read(version = ...). It hashes the table’s semantic
metadata:
volatile <- c("created_at", "datom_version", "parquet_sha",
"column_hashes", "size_bytes")
semantic <- metadata[setdiff(names(metadata), volatile)]
sorted <- semantic[sort(names(semantic), method = "radix")]
canonical <- jsonlite::toJSON(sorted, auto_unbox = TRUE)
digest::digest(canonical, algo = "sha256", serialize = FALSE)Three details are load-bearing:
serialize() is type-sensitive
(10L and 10 differ); JSON-parsed metadata has
lost that distinction. A reader pulling metadata from storage must
compute the same metadata_sha as the writer who built it in
memory, so the shared representation is the JSON text.
serialize = FALSE is what makes digest() hash
those bytes.method = "radix" sorts field names by byte
value. Plain sort() is locale-collated, so two
machines with different LC_COLLATE would order fields
differently and produce different versions for identical metadata.created_at and datom_version are wall-clock
and package facts. parquet_sha and size_bytes
move with the arrow version for identical content – letting them in
would undo the whole point of hashing values. column_hashes
is a deterministic function of the same values that produce
data_sha, so it carries no independent information.Conversely, original_file_sha and hash_algo
are semantic: a new source file is a new version of the
table’s provenance, and a new hash algorithm is a new identity
regime.
When a version’s metadata_sha already appears
anywhere in version_history.json, no entry
is appended – the current pointer in metadata.json is still
updated. This matters when you re-sync an older file that
content-matches: without a full-history scan, that write would append a
second entry with a version value already present, and a
later datom_read(version = ...) would report the SHA as
ambiguous.
parquet_sha: stored-object integrityparquet_sha is the SHA-256 of the exact parquet object
in storage. It is not identity – it is a tamper and corruption
check:
metadata_only (the content is
unchanged), the current parquet_sha is carried
forward and nothing is uploaded.parquet_sha is reused and the stored
object is left alone. Re-serializing can differ byte-for-byte, which
would break the older version’s integrity pin for no benefit.parquet_sha, and the check is skipped
rather than failed – a read-time grace, not a migration.Every datom_write() (including the one inside
datom_sync()) lands in exactly one of three outcomes:
| Outcome | When | Effect |
|---|---|---|
none |
data_sha and metadata_sha both
unchanged |
no commit, no upload, no history entry |
metadata_only |
same data_sha, different metadata_sha
(e.g. a re-export with new original_file_sha, or new
parents) |
new version, parquet_sha carried forward, no
upload |
full |
data_sha changed |
new version, parquet stored at
{name}/{data_sha}.parquet |
The metadata_only case is the one that pays for the
whole design: an EDC system re-exporting the same data with a new
timestamp in the header produces different file bytes and identical
content. datom records the new provenance and stores nothing twice.
Alongside data_sha, metadata carries
column_hashes: an ordered array of
{name, sha}, one entry per column, in table column order,
untruncated. It is the same per-column digest that fed
data_sha, computed once and reused.
Two things follow. data_sha can be re-derived from the
index plus the dimensions without downloading any data, which makes it
cheaply self-checkable. And two versions that differ in exactly one
column differ in exactly one entry – the anchor a future column-level
diff builds on.
renv:
guaranteed. The goldens in the test suite pin the byte layout
across the supported platform matrix.datom-cv1 is defined in terms of bytes – UTF-8 tags,
little-endian IEEE-754 doubles, NUL-terminated strings, SHA-256 – not R
semantics. dev/datom_cv1_reference.R is a standalone base-R
+ digest reference implementation that the package is
tested byte-for-byte against, and it doubles as the spec any other
language would implement. What is not claimed is that
an arbitrary Python or Julia reader will agree by accident; agreement
requires implementing the same encodings, starting with that
reference.original_file_sha is a SHA-256 of the
file’s bytes, so any tool in any language computes it identically.
Change detection on inputs therefore does not depend on the hashing
algorithm at all.data_sha is a portable identity of doubles.
Getting from text to doubles is a separate step, and in
base R it is neither correctly rounded nor platform-independent. R does
its own decimal parsing (R_strtod in
src/main/util.c): it accumulates digits into a
long double and then scales by a power of ten. So the
result depends on how wide long double is on the build –
and as.numeric(), decimal literals, scan() and
read.csv() all go through it.
This is known, accepted R behaviour rather than a bug: CRAN runs a
dedicated check flavour with R configured
--disable-long-double precisely because results differ, and
package authors are told to tolerate it.
Check your own platform in one line:
8 means no extra precision, and decimal parsing on that
build deviates from the correctly-rounded value. It is
not an Apple-versus-Intel split:
| Build | long double |
Decimal parsing |
|---|---|---|
| x86_64 (Linux, Windows, Intel mac) | 80-bit extended | agrees with correct rounding on everything tested |
| aarch64 Linux, s390x, riscv64 | 128-bit quad | agrees |
| Apple silicon macOS, 32-bit ARM | same as double |
deviates |
any build with --disable-long-double (CRAN’s noLD
flavour) |
forced to double |
deviates |
Note that Linux and macOS on the same arm64 chip family fall
on opposite sides – the variable is the long double width,
not the architecture.
When it bites. Deviation is possible whenever
mantissa * 10^exponent cannot be reached by a single
correctly-rounded operation at the platform’s long double
precision – that is, when the accumulated mantissa exceeds the
significand, or 10^|exponent| is not itself exactly
representable. 10^22 is the largest power of ten exactly
representable as a double, and 10^23 the first that is not,
which is why on Apple silicon a bare 1e-23 is the first
one-digit value to drift. Longer mantissas drift sooner:
3.14159265358979e-9 is already 1 ULP off, and
1e300 / 1e-300 are 4. Short decimals –
0.1, 1, 1e15,
3.14159265358979 – are exact and agree everywhere, because
only a single rounding occurs.
The consequence for datom. Two machines whose builds
fall on opposite sides of that table can parse different
doubles from the same CSV text, and will then compute different
data_sha values – both correct, for genuinely different
tables. No hash can paper over it; the divergence happens before datom
sees the data.
Mitigations, in order of preference:
arrow, or store as parquet.
Arrow’s CSV reader uses fast_float, which is correctly
rounded and platform-independent by design; parquet stores the doubles
themselves, so no decimal parsing happens at all. This is the
recommendation.0x1.999999999999ap-4 is exactly
0.1, and 0x1.fcp+996 a large value with no
base-10 rounding anywhere. Documented in ?NumericConstants
and portable.One warning: data.table::fread() is not
a fix. It has its own parser that is also not correctly rounded, and its
errors differ from base R’s – the two can disagree on the same
string.
Worth keeping in perspective: this is a property of the platform’s decimal parser, not of datom, and it affects any analysis reading that CSV with or without datom. What datom adds is that the difference becomes visible instead of silent.
metadata_sha,
and that SHA never appears twice in a table’s history.data_sha and one stored parquet object, with a distinct
metadata_sha per version.metadata_sha as the writer.