API
The Go API surface for Apache Iceberg Go. New to the project? Walk through the Getting Started tutorial first - the recipes here assume you already have a catalog and a table.
For configuration knobs (catalog options, FileIO credentials, table properties), see Configuration. For predicate construction details, see Row Filter Syntax and Expression DSL.
Catalog
catalog.Catalog is the entry point for everything: namespaces, tables, views.
Constructing a catalog
REST
import (
"context"
"github.com/apache/iceberg-go/catalog/rest"
)
cat, err := rest.NewCatalog(context.Background(), "rest", "http://localhost:8181",
rest.WithOAuthToken("your-token"))
SQL (SQLite, Postgres, MySQL, Oracle, MSSQL)
import (
"database/sql"
"github.com/apache/iceberg-go"
sqlcat "github.com/apache/iceberg-go/catalog/sql"
"github.com/uptrace/bun/driver/sqliteshim"
)
db, err := sql.Open(sqliteshim.ShimName, "file:catalog.db")
// handle err
cat, err := sqlcat.NewCatalog("default", db, sqlcat.SQLite, iceberg.Properties{
"warehouse": "file:///tmp/warehouse",
})
Glue
import (
"github.com/apache/iceberg-go/catalog/glue"
"github.com/aws/aws-sdk-go-v2/config"
)
awsCfg, err := config.LoadDefaultConfig(context.TODO())
// handle err
cat := glue.NewCatalog(glue.WithAWSConfig(awsCfg))
Hive
import (
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/catalog/hive"
)
cat, err := hive.NewCatalog(iceberg.Properties{},
hive.WithURI("thrift://localhost:9083"),
hive.WithWarehouse("s3://my-bucket/warehouse"))
Hadoop
import (
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/catalog/hadoop"
"context"
)
cat, err := hadoop.NewCatalog(context.Background(), "default", "file:///tmp/warehouse", iceberg.Properties{})
Via the registry
catalog.Load looks up the right backend via the type property (or uri scheme as a fallback) plus your ~/.iceberg-go.yaml. Useful when you want runtime selection:
import (
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/catalog"
)
cat, err := catalog.Load(ctx, "default", iceberg.Properties{
"type": "rest",
"uri": "http://localhost:8181",
"warehouse": "s3://my-bucket/warehouse",
})
if err != nil { panic(err) }
defer cat.Close()
Every catalog implements Close() error. Close it when done to release catalog-owned
resources. A caller-supplied database connection remains caller-owned.
Namespaces
ns := table.Identifier{"sales"}
err := cat.CreateNamespace(ctx, ns, iceberg.Properties{"owner": "data-team"})
namespaces, err := cat.ListNamespaces(ctx, nil) // []table.Identifier
exists, err := cat.CheckNamespaceExists(ctx, ns)
props, err := cat.LoadNamespaceProperties(ctx, ns)
summary, err := cat.UpdateNamespaceProperties(ctx, ns,
[]string{"deprecated"}, // removals
iceberg.Properties{"owner": "platform-team"}, // updates
)
err = cat.DropNamespace(ctx, ns)
table.Identifier is []string; use catalog.ToIdentifier("sales", "orders") (or catalog.ToIdentifier("sales.orders")) to build one from string parts.
Tables
Defining a schema
import "github.com/apache/iceberg-go"
schema := iceberg.NewSchema(1,
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
iceberg.NestedField{ID: 2, Name: "name", Type: iceberg.PrimitiveTypes.String, Required: false},
iceberg.NestedField{ID: 3, Name: "active", Type: iceberg.PrimitiveTypes.Bool, Required: false},
)
For nested types use &iceberg.StructType{...}, &iceberg.ListType{...}, or &iceberg.MapType{...}. Use NewSchemaWithIdentifiers(id, identifierIDs, fields...) to mark identifier columns.
Create
import "github.com/apache/iceberg-go/catalog"
ident := catalog.ToIdentifier("sales", "orders")
tbl, err := cat.CreateTable(ctx, ident, schema,
catalog.WithLocation("s3://my-bucket/sales/orders"),
catalog.WithProperties(iceberg.Properties{"owner": "data-team"}),
)
Optional catalog.WithPartitionSpec, catalog.WithSortOrder, and catalog.WithStagedUpdates are also available.
Load, exists, list, drop, rename
tbl, err := cat.LoadTable(ctx, ident)
exists, err := cat.CheckTableExists(ctx, ident)
for ident, err := range cat.ListTables(ctx, table.Identifier{"sales"}) {
if err != nil { /* ... */ }
fmt.Println(ident)
}
err = cat.DropTable(ctx, ident)
renamed, err := cat.RenameTable(ctx,
catalog.ToIdentifier("sales", "orders"),
catalog.ToIdentifier("sales", "orders_v2"))
ListTables returns an iter.Seq2[table.Identifier, error] that streams results.
Inspecting metadata
tbl.Identifier() // table.Identifier
tbl.Location() // string
tbl.MetadataLocation() // string (path of current metadata.json)
tbl.Metadata() // table.Metadata
tbl.Schema() // *iceberg.Schema (current)
tbl.Schemas() // map[int]*iceberg.Schema
tbl.Spec() // iceberg.PartitionSpec
tbl.SortOrder() // table.SortOrder
tbl.Properties() // iceberg.Properties
if snap := tbl.CurrentSnapshot(); snap != nil {
fmt.Println(snap.SnapshotID, snap.TimestampMs, snap.Summary)
}
// All snapshots
for _, snap := range tbl.Metadata().Snapshots() {
fmt.Println(snap.SnapshotID)
}
// Stream all manifest files across all snapshots
for mf, err := range tbl.AllManifests(ctx) {
if err != nil { /* ... */ }
fmt.Println(mf.FilePath())
}
Reading data
(t Table) Scan(opts ...ScanOption) *Scan returns a scan that you can resolve into Arrow data.
Streaming record batches
import "github.com/apache/iceberg-go/table"
scan := tbl.Scan()
arrowSchema, batches, err := scan.ToArrowRecords(ctx)
if err != nil { /* ... */ }
fmt.Println(arrowSchema)
for batch, err := range batches {
if err != nil { /* ... */ }
fmt.Printf("batch with %d rows\n", batch.NumRows())
batch.Release()
}
ToArrowRecords returns iter.Seq2[arrow.RecordBatch, error] so only one batch is in memory at a time. Always call batch.Release() to free Arrow buffers.
Materializing as an Arrow Table
arrowTbl, err := tbl.Scan().ToArrowTable(ctx)
if err != nil { /* ... */ }
defer arrowTbl.Release()
fmt.Printf("%d rows in %d cols\n", arrowTbl.NumRows(), arrowTbl.NumCols())
Projection and filters
import "github.com/apache/iceberg-go"
scan := tbl.Scan(
table.WithSelectedFields("id", "name"),
table.WithRowFilter(
iceberg.NewAnd(
iceberg.GreaterThanEqual(iceberg.Reference("id"), int64(100)),
iceberg.IsIn(iceberg.Reference("region"), "us-east", "us-west"),
),
),
table.WithLimit(1000),
table.WithCaseSensitive(true),
)
For the predicate vocabulary, see Row Filter Syntax.
Time travel
// By snapshot ID
scan := tbl.Scan(table.WithSnapshotID(snap.SnapshotID))
// As of a timestamp (milliseconds since epoch)
scan = tbl.Scan(table.WithSnapshotAsOf(time.Now().Add(-24*time.Hour).UnixMilli()))
Reading from a branch or tag
scan, err := tbl.Scan().UseRef("audit-branch")
if err != nil { /* ... */ }
arrowTbl, err := scan.ToArrowTable(ctx)
Iterating tasks for custom processing
If you need finer control (custom file readers, distributed scan planning):
scan := tbl.Scan(table.WithRowFilter(myFilter))
tasks, err := scan.PlanFiles(ctx)
if err != nil { /* ... */ }
arrowSchema, batches, err := scan.ReadTasks(ctx, tasks)
Incremental changelog scans
NewIncrementalChangelogScan returns rows added or deleted between snapshots. PlanFiles returns the tasks. Read reads a planned task list, and ToArrowRecords plans and reads. Remote planning is not supported.
schema, batches, err := tbl.NewIncrementalChangelogScan(
table.WithSelectedFields("id", "name"),
).FromSnapshotExclusive(startSnapshotID).ToArrowRecords(ctx)
FromSnapshotInclusive, FromSnapshotExclusive, and ToSnapshot bound the snapshot range. Projection, row filters, and limits apply while the rows are read. If the start snapshot has been expired, changes in expired snapshots before the oldest retained one are not reported. Each snapshot that adds deletes is planned against every live data file, narrowed only by the partition filter, so the cost follows the table size rather than the size of the snapshot range.
Each batch is the scan projection followed by three columns:
_change_type—INSERTorDELETE_change_ordinal— int32 ordinal of the snapshot that produced the change_commit_snapshot_id— snapshot ID that committed the change
Added data files are inserts. Removed data files are deletes of the rows in that file. Delete files added in the range emit the rows they newly remove. A rewritten file emits a DELETE and an INSERT for every row in that file, including rows that did not change. UPDATE rows are not produced. Delete-file handling is iceberg-go behavior.
Writing data
The shortcut methods on Table open a transaction, perform the write, and commit. Use NewTransaction directly when you need to combine multiple operations.
Append
import (
"github.com/apache/arrow-go/v18/arrow/array"
)
// From a streaming RecordReader
newTbl, err := tbl.Append(ctx, recordReader, nil /* snapshot props */)
// From an in-memory Arrow Table; batchSize controls the rolling writer
newTbl, err = tbl.AppendTable(ctx, arrowTbl, 1024, nil)
Overwrite
import "github.com/apache/iceberg-go/table"
// Replace all data
newTbl, err := tbl.Overwrite(ctx, recordReader, nil)
// Replace only rows matching a filter
newTbl, err = tbl.Overwrite(ctx, recordReader, nil,
table.WithOverwriteFilter(
iceberg.EqualTo(iceberg.Reference("date"), "2026-01-01"),
),
)
OverwriteTable is the arrow.Table variant.
Delete
newTbl, err := tbl.Delete(ctx,
iceberg.LessThan(iceberg.Reference("id"), int64(100)),
nil, /* snapshot props */
)
Add existing files
When you already have data files (e.g. produced by another writer), register them without rewriting:
txn := tbl.NewTransaction()
err := txn.AddDataFilesByPath(ctx, []string{
"s3://my-bucket/sales/orders/data/file-1.parquet",
"s3://my-bucket/sales/orders/data/file-2.parquet",
}, nil /* snapshot props */, false /* ignoreDuplicates */)
if err != nil { /* ... */ }
newTbl, err := txn.Commit(ctx)
ReplaceDataFilesByPath(ctx, filesToDelete, filesToAdd, snapshotProps) and ReplaceDataFiles(ctx, filesToDelete, dataFilesToAdd, snapshotProps, opts...) are also available on *Transaction for swapping files atomically.
Transactions
Group writes and metadata changes into one atomic snapshot:
txn := tbl.NewTransaction()
if err := txn.Delete(ctx,
iceberg.LessThan(iceberg.Reference("date"), "2026-01-01"), nil); err != nil {
/* ... */
}
if err := txn.Append(ctx, recordReader, nil); err != nil {
/* ... */
}
if err := txn.SetProperties(iceberg.Properties{"commit.user": "data-pipeline"}); err != nil {
/* ... */
}
newTbl, err := txn.Commit(ctx)
To target a specific branch:
txn, err := tbl.NewTransactionOnBranch("staging")
if err != nil { /* ... */ }
Commit can retry on conflict (ErrCommitFailed) when commit.retry.num-retries is set. See Automatic retry for the commit.retry.* table properties.
Schema and partition evolution
Schema evolution
import "github.com/apache/iceberg-go/table"
txn := tbl.NewTransaction()
err := table.NewUpdateSchema(txn, true /* caseSensitive */, false /* allowIncompatibleChanges */).
AddColumn([]string{"tip"}, iceberg.PrimitiveTypes.Float64, "Tip in dollars", false, nil).
RenameColumn([]string{"name"}, "full_name").
DeleteColumn([]string{"deprecated_field"}).
Commit()
if err != nil { /* ... */ }
newTbl, err := txn.Commit(ctx)
Reorder fields with MoveFirst, MoveBefore, or MoveAfter. Set allowIncompatibleChanges to true to permit type narrowing or making optional columns required.
Partition evolution
us := table.NewUpdateSpec(txn, true /* caseSensitive */)
us.AddField("event_time", iceberg.DayTransform{}, "event_day") // sourceColName, transform, partitionFieldName
us.RemoveField("legacy_partition")
if err := us.Commit(); err != nil { /* ... */ }
AddField chains; AddIdentity(sourceCol) is a shortcut for an identity transform; RenameField(name, newName) renames an existing partition field.
Available transforms (root iceberg package): IdentityTransform{}, YearTransform{}, MonthTransform{}, DayTransform{}, HourTransform{}, BucketTransform{NumBuckets: N}, TruncateTransform{Width: W}.
Snapshots and refs
Inspecting
if snap := tbl.CurrentSnapshot(); snap != nil {
fmt.Println(snap.SnapshotID, snap.TimestampMs, snap.Summary)
}
snap := tbl.SnapshotByID(snapshotID)
named := tbl.SnapshotByName("audit")
for _, s := range tbl.Metadata().Snapshots() {
fmt.Println(s.SnapshotID, s.Summary)
}
Branches and tags
The CLI's branch create and tag create commands (CLI) are the most ergonomic surface today. Programmatically, ref creation goes through Catalog.CommitTable with a SetSnapshotRef update:
import "github.com/apache/iceberg-go/table"
snap := tbl.CurrentSnapshot()
update := table.NewSetSnapshotRefUpdate(
"audit", // ref name
snap.SnapshotID,
table.BranchRef, // or table.TagRef
0, // maxRefAgeMs (0 = unset)
0, // maxSnapshotAgeMs (0 = unset)
0, // minSnapshotsToKeep (0 = unset)
)
reqs := []table.Requirement{
table.AssertTableUUID(tbl.Metadata().TableUUID()),
table.AssertRefSnapshotID("audit", nil), // ref must not already exist
}
_, _, err := cat.CommitTable(ctx, tbl.Identifier(), reqs, []table.Update{update})
Constants table.MainBranch, table.BranchRef, table.TagRef live in table/refs.go. A higher-level builder is on the roadmap.
Expiration and rollback
// Expire snapshots older than the table's retention properties
err := txn.ExpireSnapshots(/* options */)
// Roll back to a previous snapshot
err = txn.RollbackToSnapshot(targetSnapshotID)
Tune retention with the history.expire.min-snapshots-to-keep, history.expire.max-snapshot-age-ms, and history.expire.max-ref-age-ms table properties (see Configuration).
Snapshot integrity audit (v1 and later)
Use ValidateSnapshot to inspect a snapshot's manifest list and entries without
changing table data. This is especially useful for checking imported v1 tables
before allowing them into production.
report, err := tbl.Inspect().ValidateSnapshot(ctx, snapshotID)
if err != nil {
return err // I/O failed or the scan was canceled; report may be incomplete.
}
if !report.Valid() {
for _, issue := range report.Issues {
fmt.Printf("%s: %s (%s)\n", issue.Code, issue.FilePath, issue.ManifestPath)
}
}
The report checks manifest versions, partition spec references, content types,
manifest entry counts, duplicate manifest paths, and duplicate live content files
across all manifests in a snapshot. It also checks that live deletion vectors
have a target path, that their blob ranges fit within the recorded file size,
and that no two distinct vectors target the same data file.
Deleted entries do not count as live.
A report is valid only when Complete is true and Issues is empty.
This is a metadata-only audit: it does not check data-file contents or whether
data-file objects still exist. The audit works with both v1 manifest lists and
older v1 snapshots containing embedded manifests paths. For embedded
manifests, the partition spec ID is checked against table metadata when the
optional header provides it, or inferred when the partition fields match exactly
one historical spec. Missing, ambiguous, or mismatched partition specs are
reported as issues rather than silently accepted.
The file uniqueness and manifest requirements come from the Iceberg spec and manifest list spec.
Maintenance
Orphan file cleanup
import (
"time"
"github.com/apache/iceberg-go/table/maintenance"
)
result, err := maintenance.DeleteOrphanFiles(ctx, tbl,
maintenance.WithCleanupFilesOlderThan(72*time.Hour),
maintenance.WithCleanupDryRun(false),
maintenance.WithCleanupMaxConcurrency(8),
)
if err != nil { /* ... */ }
fmt.Printf("removed %d files\n", len(result.DeletedFiles))
Also see maintenance.WithCleanupLocation, maintenance.WithCleanupDeleteFunc, maintenance.WithPrefixMismatchMode, maintenance.WithEqualSchemes, and maintenance.WithEqualAuthorities in table/maintenance/orphan_cleanup.go.
Compaction (rewrite data files)
import "github.com/apache/iceberg-go/table"
txn := tbl.NewTransaction()
result, err := txn.RewriteDataFiles(ctx, groups /* []table.CompactionTaskGroup */, table.RewriteDataFilesOptions{})
if err != nil { /* ... */ }
newTbl, err := txn.Commit(ctx)
fmt.Printf("rewrote %d files into %d (%d -> %d bytes)\n",
result.RemovedDataFiles, result.AddedDataFiles, result.BytesBefore, result.BytesAfter)
The table/compaction subpackage provides bin-packing planning. The iceberg compact analyze and compact run CLI commands wrap the same machinery - see CLI.
Compaction tasks represent complete data files, not independent scan splits. The planner collapses split tasks for the same file into one whole-file task, rejecting inconsistent delete or lineage metadata and dropping residual filters because they select files but do not filter rows during compaction. Direct calls to ExecuteCompactionGroup reject partial ranges and residual row filters. Attached delete files are still applied when rewriting each source file.
To sort instead of bin-pack, pass table.WithCompactionSortOrder as a group option. Each group is sorted as a whole, so every output file holds a sorted, non-overlapping range of it. Output files record the order's sort_order_id when the order is one of the table's sort orders, every field has one source column, every field but the last is identity and the last preserves order (see table.WithCompactionSortOrder). Files of different groups still overlap, and by default a group is packed to one target file, so raise compaction.Config.MaxGroupSizeBytes until a group covers a partition. Set compaction.Config.RewriteAll to also rewrite files that are already well-sized:
cfg := compaction.DefaultConfig()
cfg.RewriteAll = true
cfg.MaxGroupSizeBytes = 8 << 30 // 8 GiB per group
plan, err := compaction.Analyze(ctx, tbl, cfg)
if err != nil { /* ... */ }
result, err := txn.RewriteDataFiles(ctx, groups /* from plan.Groups */, table.RewriteDataFilesOptions{
GroupOptions: []table.CompactionGroupOption{table.WithCompactionSortOrder(tbl.SortOrder())},
})
When every file of a group already records the order's sort_order_id (for example, files written by an earlier sort rewrite), every sort field is identity, and no partition of the group has more files than the scan concurrency (table.WithCompactionScanConcurrency, GOMAXPROCS by default), the files are streamed and merged, holding up to about five batches per file instead of the whole group. Otherwise a sort rewrite holds each group decoded in memory, and with the batches queued for the writer that can reach about twice the group's decoded size, so keep compaction.Config.MaxGroupSizeBytes within what one rewrite can hold. The first sort rewrite of a table always takes this in-memory path, since its files do not record the order yet. The CLI equivalent is iceberg compact run --sort [--rewrite-all] [--max-group-size BYTES], which uses the table's default sort order.
Expiring snapshots
import (
"time"
"github.com/apache/iceberg-go/table"
)
txn := tbl.NewTransaction()
err := txn.ExpireSnapshots(
table.WithOlderThan(7*24*time.Hour),
table.WithRetainLast(10),
)
if err != nil { /* ... */ }
newTbl, err := txn.Commit(ctx)
Pass table.WithPostCommit(true) to delete the unreferenced data and metadata files after the commit lands. The iceberg expire-snapshots CLI command wraps the same operation - see CLI.
Views
Views are created and loaded through catalogs that support them (REST, Hive, SQL):
import "github.com/apache/iceberg-go/view"
// Create
v, err := view.CreateView(
ctx,
"my-catalog",
table.Identifier{"analytics", "monthly_orders"},
schema,
view.Representation{SQL: "SELECT month, sum(amount) FROM orders GROUP BY month", Dialect: "spark"},
table.Identifier{"sales"}, // default namespace for unqualified names
"s3://my-bucket/views/monthly_orders",
iceberg.Properties{},
)
// Inspect
v.CurrentVersion() // *view.Version
v.CurrentSchema() // *iceberg.Schema
v.Versions() // []*view.Version
v.Schemas() // map[int]*iceberg.Schema
v.Properties() // iceberg.Properties
view.New(ident, meta, metadataLocation) constructs a view from already-loaded metadata; view.NewFromLocation(ctx, ident, metadataLocation, fsysFactory) loads metadata from disk or object storage.
Iceberg ↔ Arrow types
When iceberg-go converts an Iceberg schema to Arrow (e.g. for the scanner output) or vice versa, the type mapping is:
| Iceberg type | Arrow type |
|---|---|
boolean | arrow.FixedWidthTypes.Boolean |
int | arrow.PrimitiveTypes.Int32 |
long | arrow.PrimitiveTypes.Int64 |
float | arrow.PrimitiveTypes.Float32 |
double | arrow.PrimitiveTypes.Float64 |
decimal(p, s) | arrow.Decimal128Type{Precision: p, Scale: s} |
date | arrow.FixedWidthTypes.Date32 |
time | arrow.FixedWidthTypes.Time64us |
timestamp | &arrow.TimestampType{Unit: arrow.Microsecond} (no zone) |
timestamptz | arrow.FixedWidthTypes.Timestamp_us (UTC zone) |
timestamp_ns | &arrow.TimestampType{Unit: arrow.Nanosecond} (no zone) |
timestamptz_ns | arrow.FixedWidthTypes.Timestamp_ns (UTC zone) |
string | arrow.BinaryTypes.String |
binary | arrow.BinaryTypes.Binary |
fixed[L] | &arrow.FixedSizeBinaryType{ByteWidth: L} |
uuid | arrow.FixedWidthTypes.UUID (extension type) |
struct<...> | arrow.StructOf(...) |
list<E> | arrow.ListOf(E) (or LargeListOf if useLargeTypes) |
map<K, V> | arrow.MapOf(K, V) |
variant | arrow.ExtensionType for Variant (see Variant Type) |
Helpers in table/arrow_utils.go:
SchemaToArrowSchema(sc *iceberg.Schema, nameMapping NameMapping, useLargeTypes, includeRowLineage bool) (*arrow.Schema, error)VisitArrowSchema[T](sc *arrow.Schema, visitor ArrowSchemaVisitor[T]) (T, error)
For a writer-side schema (Arrow → Iceberg), the scanner and writers handle conversion automatically as long as your Arrow schema is compatible with the table schema.