DuckDB + Ibis (Python)

Use a dplyr-esque Python frontend

Load libraries

import ibis
import ibis.selectors as s
from ibis import _
# ibis.options.interactive = True # enforce eager execution of queries

Connect and register

## Instantiate an in-memory DuckDB connection from Ibis
con = ibis.duckdb.connect()

## Register our parquet dataset as a table called "nyc" in our connection
con.register("nyc-taxi/**/*.parquet", "nyc")
DatabaseTable: nyc
  vendor_name           string
  pickup_datetime       timestamp
  dropoff_datetime      timestamp
  passenger_count       int64
  trip_distance         float64
  pickup_longitude      float64
  pickup_latitude       float64
  rate_code             string
  store_and_fwd         string
  dropoff_longitude     float64
  dropoff_latitude      float64
  payment_type          string
  fare_amount           float64
  extra                 float64
  mta_tax               float64
  tip_amount            float64
  tolls_amount          float64
  total_amount          float64
  improvement_surcharge float64
  congestion_surcharge  float64
  pickup_location_id    int64
  dropoff_location_id   int64
  month                 int64
  year                  int64

Aside: Remember that you can create a persistent, disk-backed database by giving it an appropriate name/path. This also enables out-of-core computation for bigger than RAM data.

# con = ibis.duckdb.connect("nyc.dbb")
# con.register("nyc-taxi/**/*.parquet", "nyc")
# etc.

Reference the table from Python. We’ll call this reference object nyc too for consistency, but you could call it whatever you want (e.g., you could call it nyc_ibis to avoid potential ambiguity with the “nyc” table in our actual DuckDB connection). Printing the object to screen will give us a lazy preview.

# con.list_tables() # Optional: confirm that our table is available

nyc = con.table("nyc")
nyc
DatabaseTable: nyc
  vendor_name           string
  pickup_datetime       timestamp
  dropoff_datetime      timestamp
  passenger_count       int64
  trip_distance         float64
  pickup_longitude      float64
  pickup_latitude       float64
  rate_code             string
  store_and_fwd         string
  dropoff_longitude     float64
  dropoff_latitude      float64
  payment_type          string
  fare_amount           float64
  extra                 float64
  mta_tax               float64
  tip_amount            float64
  tolls_amount          float64
  total_amount          float64
  improvement_surcharge float64
  congestion_surcharge  float64
  pickup_location_id    int64
  dropoff_location_id   int64
  month                 int64
  year                  int64

First example

q1 = (
  nyc
  .group_by(["passenger_count"])
  .agg(mean_tip = _.tip_amount.mean())
)

To see the underlying SQL translation, use ibis.to_sql()

ibis.to_sql(q1)
SELECT
  t0.passenger_count,
  AVG(t0.tip_amount) AS mean_tip
FROM main.nyc AS t0
GROUP BY
  1

To actually execute the query and bring the result into Python, we can use the execute() method. By default this will coerce to a pandas DataFrame.

dat1 = q1.execute()
dat1
passenger_count mean_tip
0 0 0.862099
1 254 0.000000
2 249 0.000000
3 1 1.151011
4 247 2.300000
5 4 0.844519
6 3 0.962949
7 6 1.128365
8 5 1.102732
9 9 0.806800
10 177 1.000000
11 65 0.000000
12 2 1.081580
13 208 0.000000
14 10 0.000000
15 7 0.544118
16 8 0.350769
17 66 1.500000
Ibis conversion to polars

The q1.execute() method above is equivalent calling q1.to_pandas(). A q1.to_polars() equivalent has been added to the dev version of Ibis, but is not available with the latest offical release (8.0.0 at the time of writing).

Digression: Interactive Ibis use and eager execution

At the very top of this document, I commented out the ibis.options.interactive option as part of my Ibis configuration. This was because I wanted to demonstrate the default deferred (i.e., lazy) behaviour of Ibis, which is just the same as d(b)plyr in R. If you are building data wrangling pipelines, or writing scripts with potentially complex queries, you probably want to preserve this deferred behaviour and avoid eager execution.

However, there are times when you may want to default into eager execution. For example, if your dataset is of manageable size, or you are trying to iterate through different query operations… Or, you might just want to enable it so that you automatically get a nice print return object for your workshop materials. I’ll adopt the latter view, so that I can quickly demonstrate some Ibis syntax and results for the rest of this document.

ibis.options.interactive = True

Okay, let’s speed through some of the same basic queries that we’ve already seen in the DuckDB SQL and R (dplyr) pages. I won’t bother to explain them in depth. Just consider them for demonstration purposes.

Aggregation

(
  nyc
  .group_by(["passenger_count", "trip_distance"])
  .aggregate(
    mean_tip = _.tip_amount.mean(),
    mean_fare = _.fare_amount.mean()
    )
)
┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━┓
┃ passenger_count  trip_distance  mean_tip  mean_fare ┃
┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━┩
│ int64float64float64float64   │
├─────────────────┼───────────────┼──────────┼───────────┤
│               10.900.5789755.687596 │
│               28.802.66991124.652684 │
│               13.501.38417512.883433 │
│               12.901.22338011.352555 │
│               10.770.4999885.271770 │
│               11.900.9116178.669678 │
│               14.001.51674614.098944 │
│               20.900.4956655.764356 │
│               10.100.5265526.079754 │
│               21.900.7838208.720520 │
│                │
└─────────────────┴───────────────┴──────────┴───────────┘

Note that, even though we have enabled the interactive print mode, we still get lazy evalation if we assign a chain of query steps to an object (here: q3)…

q3 = (
  nyc
  .group_by(["passenger_count", "trip_distance"])
  .agg(
    mean_tip = _.tip_amount.mean(),
    mean_fare = _.fare_amount.mean()
    )
)

… but printing the query to screen enforces computation.

q3
┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━┓
┃ passenger_count  trip_distance  mean_tip  mean_fare ┃
┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━┩
│ int64float64float64float64   │
├─────────────────┼───────────────┼──────────┼───────────┤
│               12.00.9423368.932499 │
│               110.83.80877829.238556 │
│               13.01.25068611.611495 │
│               11.20.6882086.640748 │
│               11.30.7207286.941542 │
│               13.61.40402213.127911 │
│               14.11.54588014.321923 │
│               11.40.7535617.243111 │
│               23.31.17117112.518436 │
│               22.00.8105088.988705 │
│                │
└─────────────────┴───────────────┴──────────┴───────────┘

Pivot (reshape)

# now chain on pivoting (and enforce computation via printing)
(
  q3
  .pivot_longer(s.r["mean_tip":"mean_fare"])
)
┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━┓
┃ passenger_count  trip_distance  name       value    ┃
┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━┩
│ int64float64stringfloat64  │
├─────────────────┼───────────────┼───────────┼──────────┤
│               20.7mean_tip 0.433840 │
│               20.7mean_fare5.117718 │
│               10.8mean_tip 0.534714 │
│               10.8mean_fare5.360443 │
│               10.7mean_tip 0.493234 │
│               10.7mean_fare5.035307 │
│               20.3mean_tip 0.310850 │
│               20.3mean_fare4.192242 │
│               30.8mean_tip 0.414165 │
│               30.8mean_fare5.470059 │
│                │
└─────────────────┴───────────────┴───────────┴──────────┘

Joins (merges)

(As we did in the dplyr code, we’ll break this contrived join example into two steps)

mean_tips = nyc.group_by("month").agg(mean_tip = _.tip_amount.mean())
mean_fares = nyc.group_by("month").agg(mean_fare = _.fare_amount.mean())
(
  mean_tips
  .left_join(mean_fares, "month")
)
┏━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━┓
┃ month  mean_tip  month_right  mean_fare ┃
┡━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━┩
│ int64float64int64float64   │
├───────┼──────────┼─────────────┼───────────┤
│    111.2509031112.270138 │
│    101.2812391012.501252 │
│     21.03687429.942640 │
│     41.043167410.335490 │
│     11.00781719.813488 │
│     51.078014510.585157 │
│     91.254601912.391198 │
│     71.059312710.379943 │
│     81.079521810.492650 │
│    121.2376511212.313953 │
│      │
└───────┴──────────┴─────────────┴───────────┘