SSB Parquedit
A Python package for manually editing tabular data stored as Parquet files on DaplaLab — Statistics Norway's cloud data platform. Built on top of DuckDB and the DuckLake catalog, it provides a clean Python interface for creating tables, inserting data, querying results and editing rows directly from Google Cloud Storage (GCS). Intended for single-table editing. Does not support primary- and foreign keys.
Table of Contents
- Features
- Requirements
- Installation
- Usage
- Maintenance
- Advanced
- Project structure
- Contributing
- License
Features
- Auto-configuration — reads Dapla environment variables to build connection config automatically
- DuckLake catalog integration — metadata stored in PostgreSQL, data stored in GCS
- Create tables from a pandas or polars DataFrame, a JSON Schema dict, or an existing GCS Parquet file
- Insert data from a pandas or polars DataFrame or a
gs://Parquet path — rows are automatically assigned a uniquerowidwithin a table - Edit data - Update value(s) in a single row by its rowid.
- Delete rows - Delete one or more rows matching a where-condition, logged individually to the changelog.
- Query tables with where-conditions, column selection, sorting, pagination, and multiple output formats (
pandas,polars,pyarrow) - Find edits Retrieve historical column-level edits for a specified table
- Count rows
- Check table existence safely
- Partition tables by one or more columns
- Export/import catalog back up the DuckLake metadata catalog to a DuckDB file on GCS and restore it later
Requirements
- Python
>=3.12 - Access to a DaplaLab environment
- A PostgreSQL instance reachable at
localhostfor DuckLake metadata storage - A GCS bucket following the naming convention
ssb-{team-name}-data-produkt-{environment}
Python dependencies
| Package | Version |
|---|---|
duckdb |
==1.5.2 |
pandas |
>=3.0.0, <4.0.0 |
polars |
>=1.38.1, <2.0.0 |
pyarrow |
>=23.0.1, <24.0.0 |
gcsfs |
>=2026.1.0, <2027.0.0 |
click |
>=8.0.1 |
tenacity |
>=9.1.4,<10.0.0 |
Installation
poetry add ssb-parquedit
Usage
Basic setup
ParquEdit reads its connection configuration automatically from Dapla-environment variables.
from ssb_parquedit import ParquEdit
# Auto-configure from environment
con = ParquEdit()
Creating a table
Tables can be created from a pandas or polars DataFrame schema, a JSON Schema dict, or an existing Parquet file.
import pandas as pd
df = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]})
# Option 1: Create from DataFrame (empty — schema only)
con.create_table(table_name="my_table_1",
source=df,
product_name="my-product",
user_defined_id=["name"])
# Option 2: Create and immediately populate with data
con.create_table(table_name="my_table_2",
source=df,
product_name="my-product",
user_defined_id=["name"],
fill=True)
# Option 3: Create from a JSON Schema
schema = {
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
}
}
con.create_table(table_name="my_table_3",
source=schema,
product_name="my-product",
user_defined_id=["name"])
# Option 4: Create from an existing GCS Parquet file (schema inferred from file)
con.create_table(table_name="my_table_4",
source="gs://my-bucket/path/to/file.parquet",
product_name="my-product",
user_defined_id=["id", "year"])
# Option 5: Create with partitioning and immediately populate with data
con.create_table(table_name="my_table_5",
source=df,
product_name="my-product",
part_columns=["age"],
user_defined_id=["name"],
fill=True)
# Option 6: Create from a polars DataFrame
import polars as pl
df_polars = pl.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]})
con.create_table(table_name="my_table_6",
source=df_polars,
product_name="my-product",
user_defined_id=["name"],
fill=True)
Notes:
product_nameis required and is stored as a comment on the table.table_namemust be lowercase, start with a letter or underscore, contain only lowercase letters, numbers, and underscores, and be at most 20 characters.user_defined_id— a list of columns that together uniquely identify a row in a table, used to mimic a primary key.- Column names must not exceed 63 bytes when UTF-8 encoded (PostgreSQL's identifier limit). Longer names — easy to hit with non-ASCII characters like
æ/ø/å, which take 2 bytes each — raise aValueErrorat table creation instead of silently corrupting the table later.
Inserting data in an existing table
# Insert from a pandas DataFrame
con.insert_data(table_name="my_table_1",
source=df)
# Insert from a polars DataFrame
con.insert_data(table_name="my_table_6",
source=df_polars)
# Insert from a GCS Parquet file
con.insert_data(table_name="my_table_4",
source="gs://my-bucket/path/to/file.parquet")
Both pandas and polars DataFrames are supported as source for create_table() and insert_data().
Each inserted row is automatically assigned a unique rowid within the table
Editing a row
edit() updates exactly one row — identified by its rowid — and logs the change reason and comment to the DuckLake snapshot.
# First look up the rowid of the row you want to edit
result = con.view(table_name="my_table_1",
where="name = 'Alice'")
rowid = result["rowid"].iloc[0]
# Then edit it
con.edit(
table_name="my_table_1",
rowid=rowid,
changes={"name":"Alice B", "age": 33},
change_event_reason="REVIEW",
change_comment="Corrected name and age after data review",
)
changes is a dict of {column_name: new_value} pairs.
change_event_reason must be one of: OTHER_SOURCE, REVIEW, OWNER, MARGINAL_UNIT, DUPLICATE, OTHER
Deleting rows
delete_row() selects rows with a where clause — the same syntax as view() — and deletes all matching rows. Deletions of multiple rows are logged as one entry in the changelog get_edits()- The where-clause used and number of affected rows are logged.
# Delete a single row by its rowid
con.delete_row(
table_name="my_table_1",
where="rowid = 1",
change_event_reason="REVIEW",
change_comment="Removed duplicate entry",
)
# Delete multiple rows at once
con.delete_row(
table_name="my_table_1",
where="age < 18",
change_event_reason="OTHER",
change_comment="Removed underage entries",
)
change_event_reason must be one of: OTHER_SOURCE, REVIEW, OWNER, MARGINAL_UNIT, DUPLICATE, OTHER
Querying data
# View all rows (returns pandas DataFrame by default)
result = con.view(table_name="my_table_1")
# Filter with a WHERE clause
result = con.view(table_name="my_table_1", where="age > 25")
result = con.view(table_name="my_table_1", where="name = 'Alice' AND age >= 30")
# Limit and offset (pagination)
result = con.view(table_name="my_table_1",
limit=10,
offset=2)
# Select specific columns
result = con.view(table_name="my_table_1",
columns=["name", "age"])
# Sort results
result = con.view(table_name="my_table_1",
order_by="age DESC")
# Return as polars or pyarrow
result = con.view(table_name="my_table_1",
output_format="polars")
result = con.view(table_name="my_table_1",
output_format="pyarrow")
Time travel
time_travel() - Queries a table as it existed at a specific point in time, using DuckLake's time travel feature. Accepts the same where, limit, offset, columns, order_by, and output_format options as view().
# View a table as it was at a specific timestamp
result = con.time_travel(table_name="my_table_1", at_time="2026-09-26 00:00:00")
# Combine with the usual view() options
result = con.time_travel(table_name="my_table_1",
at_time="2026-09-26 00:00:00",
where="age > 25",
limit=10)
Counting rows
total = con.count(table_name="my_table_1",
where="name='Alice'")
Checking table existence
if con.exists(table_name="my_table_1"):
print("Table found")
List all tables
con.list_tables()
List edits
get_edits() - Retrieves the full changelog for a table by reading DuckLake snapshot metadata.
Each row represents a single edit, with columns for who made the change, when,
the reason, which row was affected (identified by its unique key), and the old
and new values for all modified columns.
Optionally filter by table name, or omit it to get the changelog for all tables.
# All edits for a specific table
df = con.get_edits(table_name="my_table")
# All edits across all tables
df = con.get_edits()
The returned DataFrame includes these changelog columns:
| Column | Description |
|---|---|
snapshot_time |
Timestamp of the edit |
changed_by |
User who made the edit |
change_event_reason |
Reason code (e.g. REVIEW, OWNER) |
change_comment |
Free-text comment from the editor |
table_name |
Table the edit was made on |
rowid |
Internal row identifier (NaN for deletions) |
user_defined_id |
Business key values identifying the row (None for deletions) |
old_values |
Dict of column → old value for changed columns (None for deletions) |
new_values |
Dict of column → new value for changed columns (None for deletions) |
where_clause |
Where-clause used on deletions (None for updates) |
change_type |
Type of change (UPDATE or DELETE) |
affected_rows |
Number of rows updated or deleted |
product_name |
Product name the table belongs to |
Drop table
drop_table() - Drops a table from the DuckLake catalog. By default, only removes the table from the catalog. DuckLake preserves data files and snapshot history, so edit history remains accessible via get_edits() after a normal drop.
When cleanup=True, additionally expires snapshots and deletes GCS data files. This permanently destroys all history and cannot be undone.
# Removes the table from the catalog
con.drop_table(table_name="my_table")
# Removes the table from the catalog, expires snapshots and deletes data files
con.drop_table(table_name="my_table", cleanup=True)
Maintenance
Flush inlined data
Flush inlined data materializes inlined rows into Parquet files for a table. This is a maintenance operation for workloads with frequent small writes, helping keep storage layout efficient and query performance stable. It does not change table values, only how data is physically stored. The operation is safe to run repeatedly: Running it when nothing is pending has no effect.
# Flushes inlined data for table 'my_table'
con.flush_inlined_table(table_name="my_table")
Merge adjacent files
Merge adjacent files compacts a table’s small Parquet files into fewer, larger files. This is a maintenance step for tables that receive many small writes, improving scan efficiency and reducing file-management overhead. It preserves table data and history semantics, changing only physical file layout. The operation is safe to run repeatedly: Running it when nothing is mergeable has no effect.
# Merge adjacent files for table 'my_table'
con.merge_adjacent_files(table_name="my_table")
Export catalog
export_catalog() backs up the DuckLake metadata catalog. It flushes and merges inlined data for every table, then copies all tables from the PostgreSQL-backed catalog schema into a DuckDB file, which is uploaded to GCS. Returns the full GCS path (including filename) of the exported backup file.
# Export using the default path ({data_path}/catalog-export)
backup_path = con.export_catalog()
# Export to a custom GCS path
backup_path = con.export_catalog(export_path="gs://bucket/backups")
Import catalog
import_catalog() restores the DuckLake metadata catalog from a backup file produced by export_catalog(). For every table found in the backup, existing rows in the catalog are deleted and replaced with the backed-up rows.
# Restore the catalog from a backup file
con.import_catalog(backup_file_path="gs://bucket/backups/20250101_120000_my_schema.duckdb")
Advanced
Accessing the raw DuckDB connection
ParquEdit wraps a DuckDBConnection, which exposes the underlying duckdb.DuckDBPyConnection via its .raw property. This is useful when integrating with libraries that require a native DuckDB connection, such as Ibis.
import ibis
from ssb_parquedit import ParquEdit
con = ParquEdit()
raw = con._get_connection().raw # duckdb.DuckDBPyConnection
ibis_conn = ibis.duckdb.connect(conn=raw)
table = ibis_conn.table("my_table_1")
Notes:
_get_connection()is an internal method. The raw connection shares state withParquEdit— closing either will affect both. Do not close the raw connection manually whileParquEditis still in use.- When using the raw connection, the user is resposible to provide the required information that
ParquEdit-methods gives. E.g when creating and editing tables.
Setting up local connection
Create a ParquEdit instance backed by a persistent local SQLite catalog. Useful for local development and testing without GCS or PostgreSQL access. The catalog and data files are stored at path and persist across sessions. The directory is created if it does not already exist.
con = ParquEdit().local(path="/home/onyxia/work/")
Restoring a local catalog backup with GCS data
ParquEdit.local_with_gcs_data() attaches a local DuckDB catalog file (e.g. one produced by export_catalog()) while the actual Parquet data still lives on GCS. Useful for inspecting or restoring from a DuckLake catalog backup without needing a live PostgreSQL connection. Must be used in DaplaLab to get access to GCS-buckets.
con = ParquEdit.local_with_gcs_data(catalog_path="localcopy.duckdb")
Configuring logging
ssb-parquedit uses the standard Python logging module. Each internal module gets its own logger via logging.getLogger(__name__), so the library does not configure any handlers itself — initialize logging in your own application before using ParquEdit:
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
from ssb_parquedit import ParquEdit
con = ParquEdit()
To see more detailed output (e.g. for debugging), set the level to logging.DEBUG, optionally only for ssb_parquedit's own loggers:
logging.getLogger("ssb_parquedit").setLevel(logging.DEBUG)
Project structure
src/ssb_parquedit/
├── parquedit.py # ParquEdit facade — main public API
├── connection.py # DuckDB + DuckLake catalog connection management
├── ddl.py # DDL operations (CREATE TABLE, partitioning)
├── dml.py # DML operations (INSERT, EDIT, DELETE)
├── query.py # Query operations (SELECT, COUNT, EXISTS)
├── maintenance.py # Maintenance operations (flush inlined data, merge adjacent files)
├── catalogexportimport.py # Catalog backup/restore (export/import to/from GCS)
├── functions.py # Environment helpers (Dapla config auto-detection)
├── local.py # Local DuckDB connection backed by SQLite (dev/testing)
├── local_backup.py # Local DuckDB catalog file + GCS-hosted data connection
└── utils.py # Schema utilities
Contributing
Contributions are very welcome. To learn more, see the Contributor Guide.
License
Distributed under the terms of the MIT license. SSB Parquedit is free and open source software.
Issues
If you encounter any problems, please file an issue along with a detailed description.
Credits
This project was generated from Statistics Norway's SSB PyPI Template. Maintained by Team Fellesfunksjoner at Statistics Norway (Data Enablement Department 724).
Metadata
Release files for ssb-parquedit 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ssb_parquedit-0.1.1.tar.gz | 35.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ssb_parquedit-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 72.8 kB
Release files / ssb_parquedit-0.1.1.tar.gz
| Download URL | ssb_parquedit-0.1.1.tar.gz |
|---|---|
| Size | 35.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d6f1dd8db956596aef87f0361e8e48e646f6db3e1cc9482ac8542ac53c724188
|
|
BLAKE2b-256 checksum How to use checksums |
4ff96e093ab24ff92e238e7e6eb3bb1ebc1be9dee38a544b2400cf98303c9e69
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency logRelease files / ssb_parquedit-0.1.1-py3-none-any.whl
| Download URL | ssb_parquedit-0.1.1-py3-none-any.whl |
|---|---|
| Size | 36.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a9f1b386bee0b3bc9780ec94f1eb6e8e6bcfd61e5d1d8e1878f7c21d7b5dd5de
|
|
BLAKE2b-256 checksum How to use checksums |
395c005f1134c44ba6f57b21074b1070d1f1fbc579c5be2112a6cc487c20a5bd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency log