Last Updated: June 01, 2026
For some of your web apps you develop in python, you will want to run them on the cloud so that your script can run 24/7. For some of your smaller applications, you may want to find the right free python hosting service so you don’t have to worry about the per month charges. These web applications might be a website written in flask, or using another web framework, it might be other types of python apps that runs in the background and runs your automation. This is where you can consider some of the hosting services that have a free plan and are still very easy to setup.
To find the right hosting platforms that fits your needs, you want to consider a few things:
- Ease of access to upload projects
- What type of support they provide
- What specifications that virtual server environment has to offer
One such new platform is called deta.sh. Deta is a free hosting service that can be used to provide web hosting for deploying python web applications or other types of python applications that run in the background.
The deta service, as of mid-2022, is still in the development stage and is expected to have a permanent free python hosting service so that online python applications can be setup and deployed quickly and easily. Deta is a relatively new service but is a service that is intended to compete with pythonanywhere, heroku, and similar services to run python on web servers. The service lets you host python script online without fuss directly from a command line, much like how you can check in code to github. Although it is new, it has the potential to be one of the best free python hosting there is in order to get your python online.
The platform provides you mini virtual environments (called ‘micros’) where you can host your python scripts. These can be separated into workspaces called ‘projects’ so that you can also more easily manage your environments. The way you can access/upload your code is with the command line through a password Access Token.

We will go through step by step how to run your python online. For this article, we will guide you on using deta to host a simple flask based web page so that you can have python as a webserver.
Python developer and educator with 15+ years building production systems across data engineering, web APIs, and AI tooling. Founder of Python How To Program — 270+ in-depth tutorials covering the modern Python stack.
Signing up for Deta.sh
Deta.sh is effectively a cloud python hosting service which sits on top of AWS and allows you to deploy your python code into a virtual machine (called a deta micro), store files (called data drive) and also store data (called deta base). Unlike AWS or other hosting services, you can quickly host and run your script without going through the hassle of setting up server, security configurations etc.
The Deta.sh team offers the service for free in order to allow developers to monetize the solutions where deta.sh will be able to share some of that revenue. To date, there are no paid Deta.sh hosting plans for python hosting and no intention. So you can continue to run python code online forever.
To begin with, head over to the website https://deta.sh to first create an account.

Once you have submitted, go to your email and click on the verify link.

After you click on sign-in, enter the same username and password, and you will be taken to the default page where you will have the ability to “See My Key”

Click on the “See My Key” to see your secret password. You will only be able to see it once and will not be able to see it ever again.
This is what they project key will look like:

You need both the key and the project id.
Think of the key like a password and the “Project ID” as a password. When you want to access your deta.sh to upload programs, make changes, you will need to use your project key to access your space.
If you lose your project id/key, you will not be able to recover it. However, you can create a new one with Settings->Create Key option.

One thing I’d like to call out is the Project ID. This is the ID of this particular s[ace

If you have multiple programs which access deta.sh, it is best to have separate project keys. The reason is that if one of your keys are compromised, then you can simply just change that key and not have all your applications be affected.
Setting Up Your Remote Access For Deta.sh
We will first setup deta.sh in the command line interface so that you can communicate to your deta.sh space on the cloud.
You can do this with either one of:
Mac / Linux:
curl -fsSL https://get.deta.dev/cli.sh | sh
Windows:
iwr https://get.deta.dev/cli.ps1 -useb | iex
Once that’s done, what will happen is that there will be a hidden folder called $HOME/.deta that is created (specifically in the case of Mac / Linux). It’s in this directory that the deta command line application will be found.
You can type deta --help to check that the command line tool was installed correctly

Next, you will need to create an access token so that you can connect to your deta.sh account. For this you will need to create an access token. Go to your deta.sh home page (e.g. https://web.deta.sh/) and then go back to the main projects page.

Next, click on the Create Access token under settings

Once you create token, this will create an Access Token so that you don’t need to login each time.

Copy this Access Token and then, create a file called tokens in the $HOME/.deta/ directory. Steps for Mac/Linux are:
cd $HOME/.deta
nano tokens
You can then add the following json inside the tokens file:
{
"deta_access_token": "<your access token created above>"
}
Finally, you can install the python library that will be used to access the deta components with the deta library.
pip install deta
Have a Free Python Hosting Flask on Deta.sh
To create an environment to host your python code and have python web hosting, you need to create something called a “micro“. This is almost like a mini virtual server with 128mb of memory but will not be running all the time. They will wake up, execute your code, and then go back to sleep. Deta.sh is not designed for long running applications with heavy computations (use one of the public cloud providers for that!). Also, each micro has its own python online cloud private access.
To begin with, you can use the command deta new --python <micro name>. The <micro name> is the name to label the mini-virtual name.

The above command will create a directory called flask_test with a python script called main.py

The default code in the main.py is:
def app(event):
return "Hello, world!"
At the same time, this code will be uploaded to deta.sh. If you go to the dashboard page https://web.deta.sh/ you will see a sub-menu under the Micro menu. You may need to refresh your browser if you had it open.

You will notice that there’s also a URL for this deta micro which is the end point where your application output can be accessed. Think of this simply as the console output.

If you encountered any errors, in the command line, you can type deta logs to get an output of any errors from the logs.
To make a more useful application, we can create a flask application to show a more functional webpage. In order to do this, you will need to dell deta.sh to install the flask library. You cannot use pip install unfortunately, but instead you need to use the requirements.txt instead.
First, add flask into a requirements.txt file in your local directory. So your file should simply look like this:
#requirements.txt
flask
Then in your main.py code file, you add the following, again this is in your local directory
from flask import Flask
app = Flask(__name__)
@app.route('/', methods=["GET"])
def hello_world():
return "Hello Flask World"
# def app(event):
# return "Hello, world!"
In order to now upload the changes to your micro, you will need to run the command deta deploy. This will upload the files requirements.txt and updates to main.py into your micro.
deta deploy
When executed, this should upload the code and install the libraries:

Managing Flask Forms On Free Python Hosting
Now that we have a simple static web page, we can create a more complex example where there’s a form that can be submitted. Using the weather API from openweathermap API, we can show the weather for a given location.
To get the weather data, we need to install two libraries pyowm and datetime. Hence, this will need to be added to requirements.txt.
#requirements.txt
flask
pyowm
datetime
Then for the code, the following can be updated in the main.py:
from flask import Flask, request, jsonify
import pyowm, datetime
app = Flask(__name__)
@app.route('/', methods=["GET"])
def get_location():
return """<html>
<body>
<form action="weather" method="POST">
<input name="location" type="text">
<input type="submit" value="submit">
</form>
</body>
</html>"""
@app.route('/weather', methods=["POST", "GET"])
def get_weather():
api_key = '<your open weather map API ley>'
owm = pyowm.OWM( api_key ).weather_manager()
weather_data = owm.weather_at_place('Bangalore').weather
ref_time = datetime.datetime.fromtimestamp( weather_data.ref_time ).strftime('%Y-%m-%d %H:%M')
weather_str = f"<h1>Weather Report for: {request.form['location']}</h1>"
weather_str += f"<ul>"
weather_str += f"<li><b>Time:</b> { ref_time } </li>"
weather_str += f"<li><b>Overview:</b> {weather_data.detailed_status} </li>"
weather_str += f"<li><b>Wind Speed:</b> {weather_data.wind()} </li>"
weather_str += f"<li><b>Humidity:</b> {weather_data.humidity} </li>"
weather_str += f"<li><b>Temperature:</b> {weather_data.temperature('fahrenheit')} </li>"
weather_str += f"<li><b>Rain:</b> {weather_data.rain} </li>"
weather_str += f"</ul>"
return weather_str
# def app(event):
# return "Hello, world!"
Then to upload the code into deta.sh, you can use the command deploy:
deta deloy
Once deployed, you can then go to the website – this is the endpoint that was automatically generated by deta.sh above.

def get_location()Once submitted, then a call is made to OpenWeatherMap

/ url, then the function def get_weather() is called to process the form. The variable that was passed, can be access through request.form['location']. The above code works by first providing a form through the function def get_location() which generates a very simple form through HTML:
<html>
<body>
<form action="weather" method="POST">
<input name="location" type="text">
<input type="submit" value="submit">
</form>
</body>
</html>
When the submit button is pressed, the form calls the /weather URL with the field location. Once called, then the python function def get_weather() is called upon which a call to OpenWeatherMap.org is made to get the weather data for the given location.
Conclusion
This is just a tip of the iceberg of what you can do with deta. You can also run scheduled jobs, run a NoSQL database, and have file storage as well. Contact us if you’d like us to cover these areas too.
How To Use Ibis for Portable DataFrames Across SQL Backends
Intermediate
You write a data pipeline in pandas, it works perfectly on your laptop with a CSV file, and then production arrives. The data is in BigQuery. You spend two days rewriting everything in the BigQuery Python client. Six months later, the company switches to Snowflake. You rewrite it again. Then someone asks you to run the same logic against a local DuckDB file for a quick analysis. At this point you have three versions of the same query, and they all drift apart every time business logic changes.
Ibis solves this by giving you one DataFrame API that compiles to SQL for more than 20 backends — DuckDB, BigQuery, Snowflake, Spark, PostgreSQL, SQLite, Trino, and more. You write the expression once, connect it to whichever backend you have available, and execute. Switching backends is a one-line change. Install it with pip install ibis-framework[duckdb] (swap duckdb for any backend you need, or use pip install ibis-framework[all] for every supported backend).
This tutorial covers everything you need to use Ibis effectively: connecting to backends, building portable expressions, filtering and aggregating data, joining tables, and understanding deferred vs eager execution. The final section builds a real multi-backend analysis pipeline. By the end you will know how to write data transformations that run anywhere without modification.
Ibis for Portable DataFrames: Quick Example
Here is a complete, runnable Ibis example that loads data into DuckDB, runs a query, and prints the result — all without writing a single line of SQL:
# quick_ibis.py
import ibis
import ibis.selectors as s
# Connect to an in-memory DuckDB instance (no file needed)
con = ibis.duckdb.connect()
# Create a table from a Python dict (simulates loading real data)
con.create_table(
"sales",
ibis.memtable({
"region": ["North", "South", "North", "East", "South", "East"],
"product": ["Widget", "Gadget", "Gadget", "Widget", "Widget", "Gadget"],
"revenue": [1200, 850, 960, 1100, 730, 980],
"units": [12, 8, 10, 11, 7, 9],
}),
overwrite=True,
)
# Build an expression -- nothing executes yet
sales = con.table("sales")
result = (
sales
.group_by(["region", "product"])
.aggregate(
total_revenue=sales.revenue.sum(),
total_units=sales.units.sum(),
avg_revenue=sales.revenue.mean().round(2),
)
.order_by(ibis.desc("total_revenue"))
)
# Execute and convert to pandas
df = result.to_pandas()
print(df)
region product total_revenue total_units avg_revenue
0 North Gadget 960 10 960.00
1 North Widget 1200 12 1200.00
2 East Gadget 980 9 980.00
3 East Widget 1100 11 1100.00
4 South Gadget 850 8 850.00
5 South Widget 730 7 730.00
Three things worth noticing: First, ibis.duckdb.connect() gives you an in-memory backend with no setup — perfect for development and testing. Second, ibis.memtable() wraps a plain Python dict as an Ibis table, so you can start experimenting without any files or databases. Third, the expression you build with .group_by(), .aggregate(), and .order_by() does nothing until you call .to_pandas(). This is lazy evaluation — Ibis builds an expression tree, compiles it to SQL behind the scenes, and only hits the backend when you ask for results.
The sections below show how to connect to real backends, build more complex expressions, and switch between backends with a single line change.
What Is Ibis and Why Use It?
Ibis is a Python DataFrame library that treats SQL backends as interchangeable execution engines. Instead of wrapping a single engine (like pandas wraps NumPy, or PySpark wraps Spark), Ibis defines a portable expression language that compiles down to the SQL dialect of whichever backend you connect to. The same .filter(), .group_by(), and .join() calls produce DuckDB SQL, BigQuery Standard SQL, Spark SQL, or Postgres SQL depending on your connection.
This matters most in three situations: when you develop locally but deploy to a cloud warehouse; when you need to run the same analysis across different databases without copy-pasting SQL; and when you want to use Python ergonomics (method chaining, tab-completion, Python type checking) while still pushing computation to the database engine rather than pulling data into memory.
| Library | Execution | Backends | API style | Best for |
|---|---|---|---|---|
| pandas | In-memory (NumPy) | 1 | Eager | Small data, local exploration |
| PySpark | Spark cluster | 1 | Lazy (Spark) | Hadoop ecosystem, batch ETL |
| SQLAlchemy | Any SQL DB | 20+ | ORM / Core SQL | App databases, ORM models |
| Polars | In-memory (Rust) | 1 | Lazy + Eager | Fast local analytics, CSV/Parquet |
| Ibis | Delegated to backend | 20+ | Lazy DataFrame | Portable analytics, multi-backend |
The key tradeoff with Ibis is that not every backend supports every expression. An operation that DuckDB handles natively might not be available in SQLite, so Ibis raises a TranslationError rather than silently producing wrong results. This is a feature, not a bug — it forces you to write expressions that are genuinely portable.
Connecting to Backends
Every Ibis workflow starts with a connection. The connection object is your interface to a specific backend — it handles authentication, compiles your expressions to the right SQL dialect, and executes queries. Ibis provides shortcut functions for every supported backend:
# connections.py
import ibis
# DuckDB -- in-memory (no file, no setup)
con_mem = ibis.duckdb.connect()
# DuckDB -- persistent file (data survives across sessions)
con_file = ibis.duckdb.connect("my_analytics.ddb")
# SQLite -- works with any .sqlite or .db file
con_sqlite = ibis.sqlite.connect("app_data.db")
# PostgreSQL -- requires psycopg2 installed
# con_pg = ibis.postgres.connect(
# host="localhost", port=5432,
# database="mydb", user="analyst", password="secret"
# )
# BigQuery -- requires google-cloud-bigquery installed
# con_bq = ibis.bigquery.connect(project="my-gcp-project", dataset="analytics")
# Print what tables exist in a connection
print("Tables in memory DuckDB:", con_mem.list_tables())
print("Tables in file DuckDB:", con_file.list_tables())
Tables in memory DuckDB: []
Tables in file DuckDB: []
For cloud backends like BigQuery or Snowflake, authentication uses the same mechanism as the underlying client library — service account keys, gcloud auth application-default login, or environment variables. Ibis does not add its own auth layer, which means your existing credentials work out of the box. For development, DuckDB in-memory mode is the fastest feedback loop: zero setup, runs entirely in your process, and supports almost the entire Ibis expression set.
Loading and Querying Data
Ibis gives you several ways to get data into the system depending on where your data lives. The most common patterns are loading from Parquet or CSV files (DuckDB handles these natively), reading from an existing database table, or wrapping a Python dict or pandas DataFrame using ibis.memtable():
# loading_data.py
import ibis
import tempfile, os, json
con = ibis.duckdb.connect()
# Pattern 1: memtable from a Python dict (great for tests and examples)
orders = ibis.memtable({
"order_id": [1001, 1002, 1003, 1004, 1005],
"customer": ["Alice", "Bob", "Alice", "Carol", "Bob"],
"amount": [250.0, 89.99, 410.50, 130.0, 75.25],
"status": ["shipped", "pending", "shipped", "cancelled", "shipped"],
})
# Pattern 2: create a real DuckDB table for repeated use
con.create_table("orders", orders, overwrite=True)
# Pattern 3: read back from the connection as a Table expression
t = con.table("orders")
print("Schema:", t.schema())
print("Row count:", t.count().to_pandas())
# Pattern 4: read a Parquet file directly (DuckDB backend)
# parquet_table = con.read_parquet("data/sales_2025.parquet")
# Preview the first 3 rows
print(t.limit(3).to_pandas())
Schema: ibis.Schema {
order_id int64
customer string
amount float64
status string
}
Row count: 5
order_id customer amount status
0 1001 Alice 250.00 shipped
1 1002 Bob 89.99 pending
2 1003 Alice 410.50 shipped
ibis.memtable() is the Swiss Army knife of Ibis development — you can wrap a pandas DataFrame, a list of dicts, or a raw dict of columns, and Ibis will handle the type inference. Notice that t.count() is also a lazy expression; you call .to_pandas() to pull the scalar result out. Ibis is consistently lazy by default — nothing touches the database until you request results.
Filtering, Selecting, and Transforming
Ibis expressions are built by chaining methods on a Table object. Filtering uses .filter(), column selection uses .select() or .drop(), and new columns are added with .mutate(). All of these return new expression objects — the original table is never modified, and nothing executes until you call .to_pandas(), .to_pyarrow(), or .execute():
# filtering.py
import ibis
con = ibis.duckdb.connect()
orders = ibis.memtable({
"order_id": [1001, 1002, 1003, 1004, 1005],
"customer": ["Alice", "Bob", "Alice", "Carol", "Bob"],
"amount": [250.0, 89.99, 410.50, 130.0, 75.25],
"status": ["shipped", "pending", "shipped", "cancelled", "shipped"],
})
t = orders # memtable is already an expression
# Filter: shipped orders over $100
shipped_large = t.filter([
t.status == "shipped",
t.amount > 100,
])
# Add a derived column: tax estimate at 10%
with_tax = shipped_large.mutate(
tax=shipped_large.amount * 0.10,
total=(shipped_large.amount * 1.10).round(2),
)
# Select only the columns we care about
result = with_tax.select("order_id", "customer", "amount", "tax", "total")
print(result.to_pandas())
order_id customer amount tax total
0 1001 Alice 250.00 25.0 275.00
1 1003 Alice 410.50 41.05 451.55
.filter() accepts a list of boolean expressions — they are AND-ed together automatically, just like WHERE a AND b in SQL. .mutate() is the equivalent of SELECT *, new_col AS expr — it keeps all existing columns and appends new ones. The expression shipped_large.amount * 0.10 does not perform any Python arithmetic; it creates an Ibis expression tree that compiles to amount * 0.10 in SQL. When you call .to_pandas(), the entire chain compiles to a single SQL query and executes in one shot.
Grouping and Aggregating
Aggregation in Ibis follows the .group_by().aggregate() pattern, which maps cleanly to SQL’s GROUP BY. You can mix any combination of aggregate functions: .sum(), .mean(), .count(), .min(), .max(), .std(), and more. Conditional aggregation (the equivalent of SUM(CASE WHEN ...) in SQL) uses .sum(where=...):
# aggregation.py
import ibis
orders = ibis.memtable({
"order_id": [1001, 1002, 1003, 1004, 1005, 1006, 1007],
"customer": ["Alice", "Bob", "Alice", "Carol", "Bob", "Alice", "Carol"],
"amount": [250.0, 89.99, 410.50, 130.0, 75.25, 195.0, 310.0],
"status": ["shipped", "pending", "shipped", "cancelled", "shipped", "shipped", "shipped"],
})
# Group by customer, aggregate multiple metrics
summary = (
orders
.group_by("customer")
.aggregate(
total_orders=orders.order_id.count(),
total_revenue=orders.amount.sum().round(2),
avg_order=orders.amount.mean().round(2),
shipped_revenue=orders.amount.sum(where=orders.status == "shipped").round(2),
)
.order_by(ibis.desc("total_revenue"))
)
print(summary.to_pandas())
customer total_orders total_revenue avg_order shipped_revenue
0 Alice 3 855.50 285.17 855.50
1 Carol 2 440.00 220.00 310.00
2 Bob 2 165.24 82.62 75.25
The where= argument on .sum() and other aggregates is equivalent to a SQL FILTER (WHERE ...) clause or a CASE WHEN inside the aggregate — it lets you compute conditional totals without subqueries. This compiles to backend-specific SQL automatically: DuckDB uses SUM(amount) FILTER (WHERE status = 'shipped'), while backends that do not support that syntax fall back to SUM(CASE WHEN status = 'shipped' THEN amount ELSE 0 END).
Joining Tables
Ibis supports all SQL join types: inner, left, right, outer, and semi/anti joins. The .join() method takes a second table and a join condition (or a list of column name strings for equi-joins). After a join, use .select() to pick the columns you want, since both tables’ columns are available and some names may collide:
# joins.py
import ibis
orders = ibis.memtable({
"order_id": [1001, 1002, 1003, 1004],
"customer_id": [1, 2, 1, 3],
"amount": [250.0, 89.99, 410.50, 130.0],
"status": ["shipped", "pending", "shipped", "shipped"],
})
customers = ibis.memtable({
"customer_id": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Carol", "Dave"],
"tier": ["gold", "silver", "gold", "bronze"],
})
# Inner join: only orders with a matching customer
result = (
orders
.join(customers, orders.customer_id == customers.customer_id)
.select(
"order_id",
"name",
"tier",
"amount",
"status",
)
.filter(lambda t: t.tier == "gold") # filter after join
.order_by("order_id")
)
print(result.to_pandas())
order_id name tier amount status
0 1001 Alice gold 250.00 shipped
1 1003 Alice gold 410.50 shipped
After a join, column references can get ambiguous if both tables share column names. Ibis uses orders.customer_id on the left and customers.customer_id on the right to disambiguate — the join condition references both explicitly. The .select() after the join picks clean output columns by name, avoiding duplicates. You can also pass how="left", how="right", or how="outer" to the .join() call; the default is an inner join.
Switching Backends
The core promise of Ibis is that your expressions are backend-agnostic. Once you have an expression, switching backends is a matter of changing the connection. The expression itself — the chain of .filter(), .group_by(), .join() calls — does not change at all. This pattern is most useful in testing: develop and test against fast in-memory DuckDB, then run the same expression against the production Snowflake warehouse without modifying the expression code:
# switching_backends.py
import ibis
# Define the expression independently of any backend
def build_summary_expr(orders_table):
"""Returns the aggregation expression -- no backend reference."""
return (
orders_table
.filter(orders_table.status == "shipped")
.group_by("customer")
.aggregate(
order_count=orders_table.order_id.count(),
total_revenue=orders_table.amount.sum().round(2),
)
.order_by(ibis.desc("total_revenue"))
)
SAMPLE_DATA = {
"order_id": [1001, 1002, 1003, 1004, 1005],
"customer": ["Alice", "Bob", "Alice", "Carol", "Bob"],
"amount": [250.0, 89.99, 410.50, 130.0, 75.25],
"status": ["shipped", "pending", "shipped", "cancelled", "shipped"],
}
# Backend A: DuckDB in-memory (development / testing)
con_dev = ibis.duckdb.connect()
orders_dev = ibis.memtable(SAMPLE_DATA)
expr_dev = build_summary_expr(orders_dev)
print("DuckDB result:")
print(expr_dev.to_pandas())
# Backend B: DuckDB file (local persistence)
con_prod = ibis.duckdb.connect("analytics.ddb")
con_prod.create_table("orders", ibis.memtable(SAMPLE_DATA), overwrite=True)
orders_prod = con_prod.table("orders")
expr_prod = build_summary_expr(orders_prod)
print("\nDuckDB file result (same expression):")
print(expr_prod.to_pandas())
DuckDB result:
customer order_count total_revenue
0 Alice 2 660.50
1 Bob 1 75.25
DuckDB file result (same expression):
customer order_count total_revenue
0 Alice 2 660.50
1 Bob 1 75.25
The key design pattern here is to put your transformation logic inside a function that accepts a table expression and returns an expression, without ever touching a specific connection. The connection only appears at the call site when you bind the function to a real table. This is the “expression first, connection second” pattern, and it is what makes Ibis code genuinely portable. For cloud backends, you would replace ibis.memtable(SAMPLE_DATA) with con_bq.table("dataset.orders") and the expression function stays identical.
Understanding Lazy Evaluation and to_pandas()
Ibis is lazy by default, which means every method call builds up a query plan without touching the database. This has two practical benefits: expressions compose cheaply (you can build a complex query without any I/O), and the backend optimizer sees the whole query at once rather than being forced into multiple round-trips. Understanding when execution happens lets you avoid accidentally running expensive queries in a loop:
# lazy_execution.py
import ibis
con = ibis.duckdb.connect()
orders = ibis.memtable({
"order_id": list(range(1, 11)),
"customer": ["Alice", "Bob"] * 5,
"amount": [100.0 + i * 15 for i in range(10)],
"status": ["shipped", "pending"] * 5,
})
# Build a complex expression -- zero database calls so far
shipped = orders.filter(orders.status == "shipped")
enriched = shipped.mutate(discounted=shipped.amount * 0.95)
summary = enriched.group_by("customer").aggregate(
total=enriched.amount.sum(),
discounted_total=enriched.discounted.sum().round(2),
)
# Print the SQL Ibis will run (useful for debugging and auditing)
print("SQL Ibis will execute:")
print(ibis.to_sql(summary))
print()
# This triggers the single database call
df = summary.to_pandas()
print("Result:")
print(df)
SQL Ibis will execute:
SELECT
"customer",
SUM("amount") AS "total",
ROUND(SUM("amount" * 0.95), 2) AS "discounted_total"
FROM (
SELECT
"customer",
"amount",
"amount" * 0.95 AS "discounted"
FROM "ibis_memtable_..."
WHERE "status" = 'shipped'
) t0
GROUP BY "customer"
Result:
customer total discounted_total
0 Alice 375.0 356.25
1 Bob 450.0 427.50
ibis.to_sql(expr) is an invaluable debugging tool — it shows you exactly what SQL your expression will produce, formatted for the connected backend. This is useful when you need to audit query performance, add database-side hints, or verify that a complex expression compiles the way you expect. The SQL is always generated fresh from the expression tree, so it always reflects the current state of your chain.
Real-Life Example: Multi-Backend Sales Analysis Pipeline
This example builds a realistic sales analysis pipeline where the transformation logic is defined once and executed against two different backends — a fast in-memory DuckDB instance for quick iteration, and a persistent DuckDB file simulating a “production” warehouse:
# sales_pipeline.py
import ibis
import pandas as pd
# -------------------------------------------------------------------
# Step 1: Define the transformation logic (backend-agnostic)
# -------------------------------------------------------------------
def compute_customer_scorecard(orders_table):
"""
Given an orders table, return a customer scorecard expression.
Works on any backend that supports the Ibis expressions used here.
"""
t = orders_table
return (
t.group_by("customer_id")
.aggregate(
lifetime_orders=t.order_id.count(),
lifetime_revenue=t.amount.sum().round(2),
avg_order_value=t.amount.mean().round(2),
shipped_orders=t.order_id.count(where=t.status == "shipped"),
cancelled_orders=t.order_id.count(where=t.status == "cancelled"),
)
.mutate(
# Derived column: fulfillment rate
fulfillment_rate=lambda t: (
(t.shipped_orders / t.lifetime_orders * 100).round(1)
)
)
.order_by(ibis.desc("lifetime_revenue"))
)
def tag_customer_tier(scorecard_expr):
"""Apply a tier label based on lifetime revenue."""
t = scorecard_expr
return t.mutate(
tier=ibis.case()
.when(t.lifetime_revenue >= 1000, "Platinum")
.when(t.lifetime_revenue >= 500, "Gold")
.when(t.lifetime_revenue >= 200, "Silver")
.else_("Bronze")
.end()
)
# -------------------------------------------------------------------
# Step 2: Sample data (in production this would be a real table)
# -------------------------------------------------------------------
ORDERS = {
"order_id": list(range(1, 16)),
"customer_id": [1, 2, 3, 1, 2, 3, 4, 1, 2, 4, 3, 1, 4, 2, 3],
"amount": [
250.0, 89.99, 410.50, 130.0, 75.25, 310.0,
95.0, 480.0, 210.0, 55.0, 175.0, 320.0, 440.0, 35.0, 290.0
],
"status": [
"shipped", "pending", "shipped", "shipped", "shipped", "cancelled",
"shipped", "shipped", "shipped", "pending", "shipped", "shipped",
"shipped", "cancelled", "shipped",
],
}
# -------------------------------------------------------------------
# Step 3: Run on Backend A (in-memory DuckDB -- fast dev iteration)
# -------------------------------------------------------------------
con_dev = ibis.duckdb.connect()
orders_dev = ibis.memtable(ORDERS)
scorecard = compute_customer_scorecard(orders_dev)
tiered = tag_customer_tier(scorecard)
print("=== Dev Backend (in-memory DuckDB) ===")
print(tiered.to_pandas().to_string(index=False))
# -------------------------------------------------------------------
# Step 4: Run identical logic on Backend B (file-based DuckDB)
# -------------------------------------------------------------------
con_prod = ibis.duckdb.connect("warehouse.ddb")
con_prod.create_table("orders", ibis.memtable(ORDERS), overwrite=True)
orders_prod = con_prod.table("orders")
scorecard_prod = compute_customer_scorecard(orders_prod)
tiered_prod = tag_customer_tier(scorecard_prod)
print("\n=== Prod Backend (file DuckDB) ===")
print(tiered_prod.to_pandas().to_string(index=False))
# Clean up the demo file
import os
os.remove("warehouse.ddb")
=== Dev Backend (in-memory DuckDB) ===
customer_id lifetime_orders lifetime_revenue avg_order_value shipped_orders cancelled_orders fulfillment_rate tier
1 4 1180.0 295.00 4 0 100.0 Platinum
3 5 1185.5 237.10 4 1 80.0 Platinum
2 5 410.24 82.05 3 1 60.0 Gold
4 3 590.0 196.67 2 0 66.7 Gold
=== Prod Backend (file DuckDB) ===
customer_id lifetime_orders lifetime_revenue avg_order_value shipped_orders cancelled_orders fulfillment_rate tier
1 4 1180.0 295.00 4 0 100.0 Platinum
3 5 1185.5 237.10 4 1 80.0 Platinum
2 5 410.24 82.05 3 1 60.0 Gold
4 3 590.0 196.67 2 0 66.7 Gold
The identical results from both backends confirm the portability. To run this against a real BigQuery or Snowflake warehouse, replace ibis.duckdb.connect() with ibis.bigquery.connect(project="...", dataset="...") or ibis.snowflake.connect(...), and replace ibis.memtable(ORDERS) with con.table("orders") pointing at your existing table. The compute_customer_scorecard() and tag_customer_tier() functions require zero changes.
Frequently Asked Questions
When should I use Ibis instead of pandas?
Use Ibis when your data lives in a database or data warehouse and you want to keep computation there instead of pulling everything into memory. If you have a 500MB CSV file and pandas handles it comfortably, you do not need Ibis. But if your data is in BigQuery, Snowflake, or a DuckDB file measured in gigabytes, Ibis lets you filter, aggregate, and join at the database layer before materializing the result with .to_pandas(). Ibis also makes sense when you need the same transformation logic to run against different databases in different environments — development, staging, and production with different backends.
What happens if I use an operation the backend does not support?
Ibis raises a com.ibis_framework.common.exceptions.OperationNotDefinedError or a backend-specific TranslationError at expression evaluation time. This is intentional — Ibis tells you that your expression cannot be compiled for that backend rather than silently computing wrong results. The practical fix is to either find the Ibis equivalent that the backend supports, or to pull the data into pandas first with a simpler query and finish the computation locally. The ibis.options.interactive = True setting makes Ibis eager (executes immediately), which can make these errors appear closer to the problem line during development.
What is ibis.options.interactive and when should I use it?
Setting ibis.options.interactive = True turns Ibis into an eager evaluator — expressions execute and display results immediately, similar to how a pandas DataFrame prints when you type it in a Jupyter notebook. This is very convenient for exploration and debugging because you do not need to call .to_pandas() constantly. Turn it off (ibis.options.interactive = False) in production pipelines, where you want full control over when queries execute and want to compose complex expressions before triggering any I/O.
Does Ibis add significant overhead compared to writing SQL directly?
The overhead from Ibis expression compilation is negligible — typically under one millisecond. The compiled SQL is then sent to the backend exactly as if you had written it by hand, so query performance is determined entirely by the backend engine and your expression logic, not by Ibis itself. In practice, Ibis often produces better SQL than hand-written queries because it consistently generates clean, optimizer-friendly SQL patterns without the human tendency to write multi-step subqueries or redundant intermediate steps.
Can I mix raw SQL with Ibis expressions?
Yes — Ibis provides ibis.expr.types.relations.Table.sql() and the con.sql() method to incorporate raw SQL into an Ibis workflow. Call con.sql("SELECT ... FROM ...") to run a raw SQL string and get back an Ibis Table expression you can then chain further Ibis operations on. This escape hatch is useful for backend-specific SQL features that Ibis does not expose through its expression API, or for migrating existing SQL queries incrementally — you can wrap the raw SQL today and gradually replace sections with portable Ibis expressions over time.
What are Ibis selectors and when do I need them?
Ibis selectors (imported as import ibis.selectors as s) let you select columns by type or pattern rather than by name. For example, s.numeric() selects all numeric columns, s.string() selects all string columns, and s.matches("revenue") selects columns whose names match a regex pattern. This is useful when you are writing generic transformations that should apply to all columns of a certain type without hardcoding column names — common in data cleaning pipelines, feature engineering, and schema-flexible ETL workflows.
Conclusion
Ibis gives you a portable DataFrame API that compiles to SQL for more than 20 backends. The key patterns to keep in your toolkit are: ibis.memtable() for quick in-memory development, .filter().mutate().group_by().aggregate() for building transformations, ibis.to_sql() for inspecting the generated SQL, and the “expression first, connection second” function pattern for genuinely backend-agnostic code. The lazy evaluation model means your expression logic composes without any I/O until you call .to_pandas(), .to_pyarrow(), or .execute().
The real-life example in this tutorial is a solid foundation for a production analytics pipeline. You can extend it by adding more derived metrics to compute_customer_scorecard(), connecting to a real Postgres or BigQuery backend instead of DuckDB, writing the result back with con.create_table("scorecard", tiered), or scheduling the pipeline to run daily and append results to a history table. The transformation functions stay identical across all of these changes.
The official Ibis documentation at ibis-project.org covers every supported backend, the full expression API reference, and backend-specific features. The Expressions guide is particularly useful once you are comfortable with the basics and want to explore window functions, user-defined functions, and the full selector API.
Related Articles
Related Articles
- How To Use Python snoop for Function Tracing and Debugging
- How To Use Python responses for Mocking HTTP Requests in Tests
- How To Use Python freezegun for Mocking Time in Tests
- How To Use Python natsort for Natural Sort Order
- How To Use Python pendulum for Better Date and Time Handling
- How To Use Python structlog for Structured Logging
- Understanding Python 3.13 Free-Threaded Mode (No GIL)
Further Reading: For more details, see the Python virtual environments documentation.
Frequently Asked Questions
Is Deta still free for hosting Python apps?
Deta Space offers a free tier for personal use. The original Deta.sh Micros service has evolved. For free Python hosting alternatives, consider Railway, Render, PythonAnywhere, or Google Cloud Run’s free tier.
What are the best free Python hosting alternatives?
PythonAnywhere offers a free tier for web apps. Render provides free static sites and web services. Railway has a free trial. Google Cloud Run and AWS Lambda have generous free tiers for serverless deployments.
How do I deploy a Python Flask app for free?
Use Render (connect GitHub repo), PythonAnywhere (upload directly), or Railway (deploy from GitHub). Each provides different advantages for hobby and small-scale projects.
What should I consider when choosing Python hosting?
Consider free tier limits, sleep/cold-start behavior, database availability, custom domain support, deployment method, Python version support, and scaling options.
Can I host a Python bot or script for free?
Yes. PythonAnywhere allows always-on tasks. Google Cloud Functions and AWS Lambda handle event-driven scripts. For Discord/Telegram bots, Railway and Render offer free tiers suitable for small bots.
Continue Learning Python
Tutorials you might also find useful: