---
title: "Writing: Appends, Tables and What Is Refused"
author: "Youzhi Yu
University of Chicago"
bibliography: vignette_reference.bib
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{Writing: Appends, Tables and What Is Refused}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r, include = FALSE}
knitr::opts_chunk$set(
collapse = TRUE,
comment = "#>"
)
library(icebergr)
# Handles and catalogs print their `location:` / `warehouse:`, which is a
# temporary directory unique to whichever machine built this page. Printing it
# would bake that path into the shipped HTML, so it is replaced here.
show <- function(x) {
writeLines(gsub(tempdir(), "", capture.output(print(x)), fixed = TRUE))
}
```
Writing is where a table format earns its keep: a commit is a table-format
operation, not a query [@icebergspec]. This version writes appends, creates
tables and namespaces, and registers existing tables. Everything it will not do,
it refuses before writing anything.
Every example here runs against a local warehouse on your own machine.
```{r}
warehouse <- file.path(tempdir(), "icebergr-writing")
dir.create(warehouse, showWarnings = FALSE)
catalog <- icebergr_catalog("memory", warehouse = warehouse)
show(catalog)
```
The `memory` catalog keeps its table pointers in the R session and its data on
disk. It needs no server, which makes it the right thing for tests and for
scratch work; `vignette("catalog-configuration")` covers REST and AWS Glue, which
are what you use when the table has to outlive the session.
## A namespace, then a table
```{r}
icebergr_create_namespace(catalog, "shop")
icebergr_list_namespaces(catalog)
```
`icebergr_create_table()` takes a data frame, but **only to read its schema off:
no rows are written**. The new table is empty, with no snapshot at all:
```{r}
template <- data.frame(
id = integer(),
item = character(),
amount = double(),
day = as.Date(character())
)
orders <- icebergr_create_table(catalog, "shop.orders", template)
show(orders)
```
`snapshot: ` is the tell. An Iceberg table with no commits is a legitimate
state, a schema with no data, and it is what you get here. The rows come next.
## Appending
```{r}
orders <- icebergr_append(orders, data.frame(
id = 1:3,
item = c("mug", "kettle", "mug"),
amount = c(9.5, 42, 9.5),
day = as.Date("2026-07-01") + 0:2
))
icebergr_collect(orders)
```
`icebergr_append()` returns a *new* handle, pointing at the snapshot the append
created. Reassigning is not optional bookkeeping; the old handle still reads the
old snapshot, by design:
```{r}
orders <- icebergr_append(orders, data.frame(
id = 4L, item = "cafetiere", amount = 24.5, day = as.Date("2026-07-04")
))
icebergr_snapshots(orders)[, c("operation", "added_records", "total_records")]
```
Two appends, two snapshots, and `vignette("time-travel")` can read either.
### Compression
`compression` picks the Parquet codec, defaulting to `zstd`:
```{r}
orders <- icebergr_append(
orders,
data.frame(id = 5L, item = "grinder", amount = 60, day = as.Date("2026-07-05")),
compression = "snappy"
)
nrow(icebergr_collect(orders))
```
Which codec to prefer is a decode-speed against file-size trade, and it is
measured across the open formats in @zeng2023columnar: dictionary encoding does
most of the work, and block compression on top of it buys less than it used to.
`zstd` is a reasonable default for cold data; `snappy` decodes faster.
## Commits are atomic, and optimistic
An append writes its Parquet, writes new manifests, and then swaps the table
pointer in one atomic step, whose failure mode is that nothing happened rather
than that half of it did. If another writer committed in between, the swap is rejected
and retried against the new state, which is how all three lakehouse formats
handle concurrency [@armbrust2020delta; @jain2023lakehouse].
For a reader this is the property that matters: a scan never sees a partial
append. That guarantee is the format's, not this package's, and it is the reason
the Hive directory convention had to be replaced [@thusoo2009hive;
@armbrust2021lakehouse].
## Registering a table someone else wrote
`icebergr_register_table()` adds an existing table to a catalog from its metadata
file, without moving or rewriting data:
```{r, eval = FALSE}
icebergr_register_table(
catalog, "shop.archive",
file.path(warehouse, "shop/archive/metadata/00007-8b1c….metadata.json")
)
```
The file has to be named the way every Iceberg engine names them,
`-.metadata.json`, and sit in the table's own `metadata`
directory, because a `memory` or `glue` catalog writes the next file beside it
and derives the next version number from its name. A renamed or moved file
registers and reads perfectly well; it is the *append* that cannot work. A REST
catalog's server chooses its own locations, so this does not apply to one.
That append is refused at the start, before any Parquet is written, and the error
names the file and its directory. Left to Iceberg the name is only inspected when the commit is
attempted, by which point the data files are already in the warehouse, and this
package exposes no maintenance operation to clear them.
It also has to sit inside the catalog's warehouse, which is what the default,
`confine = TRUE`, requires. A metadata file names absolute paths for its
data, so one from an untrusted source reads whatever its author chose; pass
`confine = FALSE` only for a file you trust.
## What is refused, and why that is the design
Several write operations are unavailable, and each fails before touching the
warehouse:
```{r}
features <- icebergr_spec_support()$features
writes <- grepl("append|delete|merge|overwrite|partition|propert",
features$feature,
ignore.case = TRUE
)
features[
writes & !is.na(features$supported) & !features$supported,
c("feature", "reason")
]
```
**Appending to a partitioned table.** Iceberg only discovers a missing partition
value at commit time, which would leave orphan Parquet in the warehouse and
report the cause in terms of neither the table nor the file. So the partition spec
is checked first, and the append is refused with nothing written. Reading a
partitioned table works, and `icebergr_partitions()` reports its spec.
**Row-level deletes and `MERGE`.** `iceberg-rust` 0.10 can write an equality
delete file but has no transaction action that commits one. Reading merge-on-read
tables another engine wrote works: both positional and equality deletes are
applied.
**Overwrites.** `fast_append` is the only way 0.10 can add files to a table.
That split, refusing loudly here while reading whatever anyone else wrote, is
deliberate.
A narrow surface that is correct is worth more than a broad one that commits
something subtly wrong, and the format is designed so that a client which does
not implement a feature can still read tables that use it [@icebergspec].
## Checking before you write
```{r}
icebergr_table_exists(catalog, "shop.orders")
icebergr_table_exists(catalog, "shop.nothing_here")
icebergr_list_tables(catalog, "shop")
```
`icebergr_properties()` reads the table's properties, and returns a zero-row
tibble when it has none, as a freshly created table does:
```{r}
icebergr_properties(orders)
```
Setting them needs an `update_properties` transaction, which is also out of scope
for this version.
# References