Ibis Tutorial: The Portable Python DataFrame API Across Backends

# Ibis: API Dataframe Python yang Portabel di Banyak Backend Ibis adalah library dataframe Python yang memungkinkan Anda menulis kode analitik satu kali lalu menjalankannya di banyak mesin eksekusi,...

By Ruby Abdullah · · tutorial
IbisDataFrameSQLData EngineeringAnalyticsPython

Ibis: The Portable Python Dataframe API Across Many Backends

Ibis is a Python dataframe library that lets you write analytics code once and run it on many execution engines, from a local DuckDB instance on your laptop to BigQuery, Snowflake, or Spark in production. Instead of rewriting pandas logic into SQL when your data outgrows memory, you describe the transformation in Python, and Ibis compiles it to the dialect of whichever backend you connect to. This tutorial walks through the core ideas: deferred execution, backend portability, and a practical end-to-end analytics example.

The Problem Ibis Solves

Most data teams hit the same wall. You prototype an analysis in pandas because it is fast to write and easy to reason about. The dataset grows, no longer fits in memory, and now you have to translate that pandas code into SQL so it can run inside the data warehouse. The two implementations drift apart, bugs creep in during translation, and you maintain two versions of the same logic.

Ibis offers a single dataframe API that sits in front of the engine. You write expressions in Python, and:

  • The same code runs on a local engine (DuckDB) during development and on a warehouse (BigQuery, Snowflake, Postgres) in production.
  • The heavy computation happens inside the engine, close to the data, not in your Python process.
  • You avoid hand-translating pandas to SQL, and you can scale from a laptop to a warehouse by changing only the connection.

The mental model is closer to a query builder than to pandas. You are composing a description of a computation, not eagerly materializing intermediate results.

Deferred (Lazy) Execution

This is the single most important concept in Ibis. When you chain operations such as filter, select, and groupby, nothing is computed. You are building an expression tree. Execution only happens when you explicitly ask for results.

import ibis

con = ibis.duckdb.connect()

This builds an expression. No query runs yet.

orders = con.table("orders")

expr = (

orders

.filter(orders.status == "completed")

.groupby("country")

.aggregate(total=orders.amount.sum())

)

Still nothing has executed. expr is just a description.

print(type(expr)) #

Execution happens here, returning a pandas DataFrame.

df = expr.execute()

The trigger points that actually run the query are:

  • .execute() — returns a pandas DataFrame (the default).
  • .topandas() — explicit pandas output.
  • .topolars() — returns a Polars DataFrame.
  • .topyarrow() — returns a PyArrow Table.
  • .head().execute() — preview a small sample.

Because execution is deferred, Ibis can push the entire pipeline down to the engine as one optimized query. That is what makes it scale: you are not pulling raw data into Python and filtering it there.

Installation

Ibis is installed with one or more backend extras. Each backend you want to use is an optional dependency, which keeps the install lean.

# Core library plus the DuckDB backend (recommended starting point)

pip install 'ibis-framework[duckdb]'

Add other backends as needed

pip install 'ibis-framework[bigquery]'

pip install 'ibis-framework[postgres]'

pip install 'ibis-framework[snowflake]'

pip install 'ibis-framework[polars]'

Multiple backends at once

pip install 'ibis-framework[duckdb,postgres,bigquery]'

Verify the installation:

import ibis

print(ibis.version)

DuckDB is the recommended local backend. It runs in-process, needs no server, and supports the full range of SQL features Ibis compiles to, which makes it ideal for development and testing.

Connecting to Backends

A connection is the object that knows how to talk to an engine. The same expression API works regardless of which connection you build.

import ibis

DuckDB in-memory (great for experiments)

con = ibis.duckdb.connect()

DuckDB persisted to a file

con = ibis.duckdb.connect("analytics.ddb")

URL-style connection (works across backends)

con = ibis.connect("duckdb://analytics.ddb")

For warehouse backends, the connection carries credentials and project details. The expressions you build afterward are identical.

# BigQuery

con = ibis.bigquery.connect(

projectid="my-gcp-project",

datasetid="analytics",

)

PostgreSQL

con = ibis.postgres.connect(

host="localhost",

user="analyst",

password="secret",

database="warehouse",

)

Snowflake

con = ibis.snowflake.connect(

user="analyst",

account="abc-xy123",

database="ANALYTICS",

warehouse="COMPUTEWH",

)

Polars (in-memory, no SQL engine)

con = ibis.polars.connect()

Note that some backends, such as Polars and pandas, are not SQL engines. Ibis still gives you the same dataframe API over them, executing through the native library instead of generating SQL.

Reading Data

Ibis can read existing tables in the backend or load files directly. DuckDB makes file reading particularly convenient.

import ibis

con = ibis.duckdb.connect()

Reference a table that already exists in the backend

orders = con.table("orders")

Read files directly (DuckDB, among others)

sales = con.readcsv("data/sales.csv")

events = con.readparquet("data/events/.parquet")

List tables registered in the connection

print(con.listtables())

You can also bring in-memory data as a memtable. This is useful for small lookup tables or test fixtures, and it works across backends.

import pandas as pd

import ibis

regions = pd.DataFrame(

{

"country": ["ID", "SG", "MY"],

"region": ["SEA", "SEA", "SEA"],

}

)

Wrap a pandas DataFrame as an Ibis table

regiontable = ibis.memtable(regions)

Core Table Operations

The operation vocabulary will feel familiar if you know SQL or pandas, but everything composes lazily.

Selecting and Filtering

import ibis

con = ibis.duckdb.connect()

orders = con.table("orders")

Select specific columns

expr = orders.select("orderid", "country", "amount")

Filter rows (multiple conditions combine with & and |)

expr = orders.filter(

(orders.amount > 100) & (orders.status == "completed")

)

The Deferred Column Reference

Ibis provides an underscore object that refers to "the current table" inside a chain. It lets you write fluent pipelines without repeatedly naming the table.

from ibis import 

expr = (

orders

.filter(.status == "completed")

.mutate(netamount=.amount - .discount)

.filter(.netamount > 50)

.orderby(.netamount.desc())

.limit(10)

)

Here always points to the table produced by the previous step, including the netamount column added by mutate, which would not exist on the original orders table.

Mutate, Order, and Limit

expr = (

orders

.mutate(amountwithtax=orders.amount 1.11)

.orderby(ibis.desc("amountwithtax"))

.limit(20)

)

Aggregation with groupby

from ibis import 

metrics = (

orders

.groupby("country")

.aggregate(

ordercount=.orderid.count(),

totalrevenue=.amount.sum(),

avgorder=.amount.mean(),

)

.orderby(.totalrevenue.desc())

)

Joins

con = ibis.duckdb.connect()

orders = con.table("orders")

customers = con.table("customers")

joined = orders.join(

customers,

orders.customerid == customers.customerid,

how="inner",

)

result = joined.select(

"orderid",

"amount",

customers.name,

customers.segment,

)

Window Functions

Window functions compute values across a set of rows related to the current row, without collapsing them like an aggregate does.

from ibis import 

Rank orders by amount within each country

ranked = orders.mutate(

rankincountry=ibis.rank().over(

ibis.window(groupby="country", orderby=.amount.desc())

)

)

String and Date Methods

Ibis exposes string and temporal methods that compile to the right backend functions.

from ibis import 

expr = orders.mutate(

countrylower=.country.lower(),

nameclean=.customername.strip(),

haspromo=.notes.contains("PROMO"),

orderyear=.orderdate.year(),

ordermonth=.orderdate.month(),

dayssince=(ibis.now() - .orderdate).cast("int64"),

)

Previewing Lazily and Inspecting Compiled SQL

Because expressions are deferred, you can inspect what Ibis will send to the engine before running anything heavy. This is invaluable for debugging and for understanding cost.

from ibis import 

expr = (

orders

.filter(.status == "completed")

.groupby("country")

.aggregate(total=.amount.sum())

)

Preview only a few rows (still pushes the query down, with a LIMIT)

print(expr.head(5).execute())

Inspect the compiled SQL without executing

print(ibis.tosql(expr))

The same expression compiles to different SQL depending on the connected backend. Below, the identical Python produces DuckDB and BigQuery dialects.

import ibis

expr = (

ibis.table({"country": "string", "amount": "float64"}, name="orders")

.groupby("country")

.aggregate(total=ibis..amount.sum())

)

print(ibis.tosql(expr, dialect="duckdb"))

print(ibis.tosql(expr, dialect="bigquery"))

-- duckdb dialect

SELECT

"country",

SUM("amount") AS "total"

FROM "orders"

GROUP BY 1

-- bigquery dialect

SELECT

country,

SUM(amount) AS total

FROM orders

GROUP BY 1

Note the difference in identifier quoting ("..." vs ` ... ). Ibis handles dialect-specific syntax, function names, and type casts so you do not have to.

Switching Backends: The Portability Payoff

This is where Ibis earns its place in a stack. You develop and test against DuckDB locally, then point the same code at BigQuery in production. The analytics logic does not change, only the connection.

import ibis

from ibis import

def revenuebycountry(con, tablename="orders"):

"""Backend-agnostic analytics. Works on any Ibis connection."""

orders = con.table(tablename)

return (

orders

.filter(.status == "completed")

.groupby("country")

.aggregate(

totalrevenue=.amount.sum(),

ordercount=.orderid.count(),

)

.orderby(.totalrevenue.desc())

)

Development: local DuckDB

devcon = ibis.duckdb.connect("local.ddb")

print(revenuebycountry(devcon).execute())

Production: BigQuery, same function, different connection

prodcon = ibis.bigquery.connect(projectid="my-project", datasetid="analytics")

print(revenuebycountry(prodcon).execute())

The function is written once and is genuinely portable. The only environment-specific detail is the connection object you pass in.

Interop with pandas, Polars, and PyArrow

Ibis is designed to fit into an existing Python data stack rather than replace it. You can move data in and out of pandas, Polars, and Arrow freely.

import ibis

con = ibis.duckdb.connect()

orders = con.table("orders")

Out: choose the output format you want

pdf = orders.limit(1000).topandas()

pldf = orders.limit(1000).topolars()

arrowtbl = orders.limit(1000).topyarrow()

In: wrap external dataframes as Ibis tables

import pandas as pd

extra = pd.DataFrame({"id": [1, 2], "label": ["a", "b"]})

extratbl = ibis.memtable(extra)

A common pattern is to use Ibis as a frontend that hands the heavy SQL to the warehouse, then pull only the small aggregated result into pandas for plotting or reporting, so only the summary crosses the network.

Scalar Python UDFs

When a backend lacks a built-in function you need, you can define a scalar user-defined function in Python. Support and performance depend on the backend (DuckDB supports Python UDFs well).

import ibis

from ibis import

@ibis.udf.scalar.python

def classifyamount(x: float) -> str:

if x >= 1000:

return "large"

elif x >= 100:

return "medium"

return "small"

con = ibis.duckdb.connect()

orders = con.table("orders")

expr = orders.mutate(sizebucket=classifyamount(.amount))

Prefer built-in Ibis operations where they exist, since they compile to native engine functions and run faster than row-by-row Python UDFs. Reach for UDFs only when the logic cannot be expressed otherwise.

The SQL Escape Hatch

Ibis does not lock you out of raw SQL. When you need a backend-specific construct or simply prefer to write a query by hand, you can drop down to SQL and keep composing on top of the result.

import ibis

from ibis import

con = ibis.duckdb.connect()

Run raw SQL and get an Ibis table back

top = con.sql(

"""

SELECT country, SUM(amount) AS total

FROM orders

WHERE status = 'completed'

GROUP BY country

"""

)

Continue building with the Ibis API on the SQL result

final = top.filter(.total > 10000).orderby(.total.desc())

Name (alias) a table expression for reuse or for use inside con.sql

labeled = top.alias("countrytotals")

This mix-and-match approach is practical: use the portable Ibis API for the bulk of your logic, and reach for con.sql(...) only for the parts that genuinely need a hand-written query.

End-to-End Example: Sales Analytics on DuckDB

The following example runs entirely on a local DuckDB instance. It builds two memtables, joins orders to customers, computes grouped metrics, and ranks orders within each segment using a window function.

import ibis

import pandas as pd

from ibis import

ibis.options.interactive = True # auto-execute and pretty-print in a REPL

con = ibis.duckdb.connect()

ordersdf = pd.DataFrame(

{

"orderid": [1, 2, 3, 4, 5, 6],

"customerid": [10, 10, 11, 12, 11, 12],

"amount": [120.0, 80.0, 500.0, 30.0, 220.0, 90.0],

"status": [

"completed",

"completed",

"completed",

"cancelled",

"completed",

"completed",

],

"orderdate": pd.todatetime(

[

"2026-01-05",

"2026-01-09",

"2026-02-01",

"2026-02-03",

"2026-02-15",

"2026-03-02",

]

),

}

)

customersdf = pd.DataFrame(

{

"customerid": [10, 11, 12],

"name": ["Andi", "Budi", "Citra"],

"segment": ["retail", "wholesale", "retail"],

}

)

orders = con.createtable("orders", ordersdf)

customers = con.createtable("customers", customersdf)

1) Join orders to customers, keep completed orders only

joined = (

orders

.filter(.status == "completed")

.join(customers, "customerid", how="inner")

)

2) Grouped metrics per customer segment

segmentmetrics = (

joined

.groupby("segment")

.aggregate(

revenue=.amount.sum(),

ordercount=.orderid.count(),

avgorder=.amount.mean(),

)

.orderby(.revenue.desc())

)

print(segmentmetrics.execute())

3) Rank each order by amount within its segment (window function)

ranked = joined.mutate(

rankinsegment=ibis.rank().over(

ibis.window(groupby="segment", orderby=.amount.desc())

)

).select("orderid", "name", "segment", "amount", "rankinsegment")

print(ranked.orderby(["segment", "rankinsegment"]).execute())

Inspect the SQL DuckDB will actually run for the ranking step

print(ibis.tosql(ranked))

This pipeline reads as ordinary Python, yet every step executes inside DuckDB as a single compiled query. Swapping con for a BigQuery connection would run the identical logic in the warehouse.

When to Use Ibis vs Polars vs Raw SQL

Each tool has a sweet spot, and they often coexist in the same project.

  • Use Ibis when you need one analytics codebase to run across multiple engines, when your data lives in a warehouse and you want to push computation down to it, or when you want to develop locally on DuckDB and deploy to BigQuery or Snowflake without a rewrite. Ibis is a frontend, not an engine.
  • Use Polars when your data fits comfortably on a single machine, you want maximum in-process performance, and you do not need backend portability. Polars is an engine and is excellent at fast local dataframe work.
  • Use raw SQL when a query is naturally expressed in SQL, is backend-specific, or when your team already maintains SQL assets. You can still wrap that SQL in Ibis via con.sql(...) to compose further.

A reasonable hybrid: prototype and explore with Polars or pandas, express the production pipeline in Ibis so it runs in the warehouse, and drop to con.sql(...) for the occasional query that needs hand tuning.

Best Practices

  • Develop on DuckDB, deploy on your warehouse. DuckDB gives a fast, server-free local loop while keeping behavior close to production SQL engines.
  • Inspect the SQL with ibis.tosql(expr) before running expensive queries. It reveals exactly what the engine will execute and helps you reason about cost.
  • Keep heavy computation in the engine. Filter and aggregate before calling .topandas(), so only small results cross into Python.
  • Use from ibis import for readable, fluent chains that reference intermediate columns, and prefer built-in operations over UDFs since they compile to native engine functions and run much faster than per-row Python.
  • Pass the connection as a parameter to your analytics functions so the same logic stays backend-agnostic and testable.
  • Pin your Ibis and backend versions in production, since dialect compilation can evolve between releases.

Conclusion and Key Takeaways

Ibis gives you a single, portable dataframe API that compiles to the SQL dialect of whichever engine you connect to, letting you write analytics once and run it from a laptop to a warehouse.

  • Ibis is a frontend over execution engines, not an engine itself. It builds expressions and hands compiled queries to backends like DuckDB, BigQuery, Postgres, Snowflake, and Spark.
  • Execution is deferred. You compose an expression tree, and nothing runs until you call .execute(), .topandas(), .topolars(), or .topyarrow().
  • The same code runs on different backends by changing only the connection, which is the central portability benefit.
  • You can inspect compiled SQL with ibis.to_sql(expr) and confirm the same expression produces different dialects per backend.
  • Ibis interoperates cleanly with pandas, Polars, and PyArrow, and offers a con.sql(...)` escape hatch for raw SQL when needed.
  • Choose Ibis for portability and warehouse-scale pushdown, Polars for fast local single-machine work, and raw SQL where it fits best, mixing them as the project requires.

Related Articles

DuckDB: In-Process Analytical Database for Data Science

DuckDB: Database Analitik In-Process untuk Data Science DuckDB adalah database analitik in-process yang dirancang khusus...

PostgreSQL Advanced for ML Tutorial: Analytics and Feature Engineering

Tutorial 17: PostgreSQL Lanjutan untuk Machine Learning Daftar Isi Pendahuluan Prasyarat Window Functions untuk Rekayasa...

dlt Tutorial: Python-First Data Ingestion Pipelines

Membangun Pipeline EL Berbasis Python dengan dlt (data load tool) Sebagian besar tim data menghabiskan waktu yang tidak ...

Pandera Tutorial: Statistical Data Validation for DataFrames

Pandera: Validasi Data Statistik untuk DataFrame pandas dan Polars Pipeline data sering gagal tanpa suara. Sebuah kolom ...