Last Updated: June 01, 2026
Beginner
Determining the current date is a public holiday can be tricky when holidays change and it of course changes from country to country. From system time of servers & machines running to timestamps for tracking the transactions and events in e-commerce platforms, the date and time play a major role. There are a variety of use cases related to manipulating date and time that can be solved using the inbuilt datetime module in Python3, such as
- Finding if a given year is a leap year or an ordinary year
- Finding the number of days between the two mentioned dates
- Convert between different date or time formats
What if you were to check if a given date is a public holiday? There isn’t any specific formula or logic to determine that, do we? Holidays can be pre-defined or uncalled for.
Here, we will be exploring the two ways to detect if a date is a holiday or not.
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.
Checking For Public Holiday With Holidays Module
Although Python3 doesn’t provide any modules to detect if a date is a holiday or not, there are some of the external modules that help in detecting this. One of those modules is Holidays.
In your terminal, type in the following to get the module installed.
sudo pip3 install holidays

Now that our module is ready, let’s understand a bit about what the module and what it is capable of. Have a look at the following code snippet.
'''
Snippet to check if a given date is a holiday
'''
from datetime import date # Step 1
import holidays
us_holidays = holidays.UnitedStates() # Step 2
input_date = input("Enter the date as YYYY-MM-DD: ") # Step 3
holiday_name = us_holidays .get(input_date) # Step 4
if holiday_name != None:
output = "{} is a US Holiday - It's {}".format(input_date, holiday_name)
else:
output = "{} is not a US Holiday".format(input_date)
# Step 5
print (output)
In the above snippet,
- Step 1: Imports the required modules
- Step 2: Initializes the us_holidays object, so that the corresponding
getfunction can be invoked at step 3 - Step 3: Gets
dateinput from the user - Step 4: Invokes the get function of the
holidaysmodule. This returns the name of the holiday if the date is a holiday or returnsNonein case if it isn’t. This gets assigned to the variable –holiday_name. - Step 5: Based on the variable –
holiday_name, using theifclause the string formatting is done. Can you make this if clause even leaner? Read this article to know about the One line if else statements.
Here’s what the output looks like.

Checking For Holidays With API Call to Calendarific
The above method is suitable for simple projects; however, it can never be used to provide an enterprise-grade solution. Let’s say, you are building a web application for a holiday and travel startup, building an enterprise-grade application requires an enterprise-grade solution. If you haven’t noticed, the holidays module is pretty simple and if you consider state-wise or newly announced holidays, then this solution doesn’t simply cut for a large-scale application.
Enterprise requirements such as these can be satisfied by using external APIs such as Calendarific which provides the API as a service for such applications to consume. They keep updating the holidays of states and countries constantly, and the applications may consume these APIs. Of course, enterprise solutions don’t always come free, but the developer account has a limit of 1000API requests per month.
Locate to https://calendarific.com/ on your favorite browser and follow the steps as shown in the following images to get yourself a free account and an API key for this exercise.




Understanding the Calendarific REST API
Before we could dive into using the API KEY, get yourself a REST API client – Insomnia or Postman. We are about to test our API key if we are able to retrieve the holiday information. Plugin the following URL by replacing [APIKEY] text with your API KEY received from above on your REST client.
https://calendarific.com/api/v2/holidays?api_key=[APIKEY]&country=us-ny&type=national&year=2020&month=1&day=1
In the above URL:
- https://calendarific.com/api/v2 is the API Base URL
- /holidays is the API route
- api_key, country, type, year, month, day are URL Parameters
- Each parameter has a value allocated to it with an = (equal sign)
- Each parameter and value pair is split by an & (ampersand)
For the above API call, the following response will be received; the value corresponding to the code key under the meta tag as ‘200’ corresponds to a successful response.
{
"meta": {
"code": 200
},
"response": {
"holidays": [
{
"name": "New Year's Day",
"description": "New Year's Day is the first day of the Gregorian calendar, which is widely used in many countries such as the USA.",
"country": {
"id": "us",
"name": "United States"
},
"date": {
"iso": "2020-01-01",
"datetime": {
"year": 2020,
"month": 1,
"day": 1
}
},
"type": [
"National holiday"
],
"locations": "All",
"states": "All"
}
]
}
}
The REST API call has returned some useful info about the National holiday on the 1st of January. Let’s see if it’s able to detect for the 2nd of January. Plugin the following URL again by replacing the text [APIKEY] with your API Key.
https://calendarific.com/api/v2/holidays?api_key=[APIKEY]&country=us-ny&type=national&year=2020&month=1&day=2
The above URL should be returning a response similar to below.
{
"meta": {
"code": 200
},
"response": {
"holidays": []
}
}
Indeed, the 2nd of January is not a public holiday and hence, the holidays list inside the response nested JSON key turns out to be an empty list.
Now we know that our API works very well, it is now time to incorporate Calendarific REST API into our Python code. We will be using the requests module in order to make this happen. Here’s how it is done.
'''
Snippet to check if a given date is a holiday using an external API - Calendarific
'''
import requests # Step 1
api_key = '[APIKEY]' # Step 2
base_url = 'https://calendarific.com/api/v2'
api_route = '/holidays'
location = input("Enter Country & State code - E.g.: us-ny: ")
date_inpt = input("Enter the date as YYYY-MM-DD: ") # Step 3
y, m, d = date_inpt.split('-')
full_url = '{}{}?api_key={}&country={}&type=national&year={}&month={}&day={}'\
.format(base_url, api_route, api_key, location, str(int(y)), str(int(m)), str(int(d))) # Step 4
response = requests.get(full_url).json() # Step 5
if response['response']['holidays'] != []:
print ("{} is a holiday - {}".format(date_inpt, response['response']['holidays'][0]['name']))
else: # Step 6
print ("{} is not a holiday".format(date_inpt))
In the above snippet,
- Step 1: Import requests module – you will be needing this module to invoke the REST API.
- Step 2: Replace ‘[APIKEY]’ with your own API key from Calendarific
- Step 3: The user inputs the corresponding location and date for which the holiday needs to be detected
- Step 4: String formatting in order to frame the URL
- Step 5: Invoke the API and convert the response to a JSON; i.e.) a dictionary
- Step 6: If clause checks for the presence of an empty list or with a returned response.
Here’s what the output looks like.

And there you have it, a working example for detecting if a given date is a holiday using an external API.
Summary
From an overall perspective, there could be multiple ways to solve a given problem, and here, we have portrayed two of those ways in detecting if a given date is a holiday or not. One is a straight forward out-of-the-box solution and the other one is an enterprise-ready solution, which one would you choose?
Subscribe to our newsletter
How To Use Narwhals for DataFrame-Agnostic Python Code
Intermediate
You write a data processing function in pandas. It works great. Then a teammate switches the project to polars for performance reasons, and suddenly half your pipeline is broken. Or you maintain an open-source library that accepts a DataFrame as input — except now you need to support pandas, polars, and maybe cuDF for GPU users, which means three different code paths for what is essentially the same logic. This is the DataFrame fragmentation problem, and it gets more painful the more libraries you support.
Narwhals is a lightweight compatibility layer that lets you write DataFrame code once and run it on pandas, polars, modin, cuDF, and any other compliant backend. Instead of writing df.rename(columns={"old": "new"}) for pandas and df.rename({"old": "new"}) for polars, you write the Narwhals version once and it dispatches to the correct backend automatically. The library has zero mandatory dependencies — if the user passes in a pandas DataFrame, Narwhals uses pandas; if they pass in a polars DataFrame, it uses polars. Your code never needs to know which one it received.
This article covers everything you need to start writing DataFrame-agnostic Python code with Narwhals. You will learn how to install it, wrap inputs with narwhals.from_native(), use the Narwhals expression API for filtering, grouping, and aggregation, write backend-agnostic library functions, and handle the conversion back to native DataFrames. By the end you will have a working data pipeline that runs identically on pandas and polars without a single if isinstance check.
Narwhals DataFrame-Agnostic Code: Quick Example
Here is the shortest possible demonstration of the core idea. The function below accepts any supported DataFrame, filters rows, and returns a result — without knowing or caring whether the caller passed in pandas or polars.
# quick_narwhals.py
import narwhals as nw
import pandas as pd
import polars as pl
def get_high_earners(df_native, threshold=70000):
df = nw.from_native(df_native)
result = df.filter(nw.col("salary") > threshold)
return nw.to_native(result)
# Works with pandas
pandas_df = pd.DataFrame({"name": ["Alice", "Bob", "Carol"], "salary": [90000, 55000, 80000]})
print(get_high_earners(pandas_df))
# Works with polars -- same function, no changes
polars_df = pl.DataFrame({"name": ["Alice", "Bob", "Carol"], "salary": [90000, 55000, 80000]})
print(get_high_earners(polars_df))
Output (pandas call):
name salary
0 Alice 90000
2 Carol 80000
Output (polars call):
shape: (2, 2)
+-------+--------+
| name | salary |
| str | i64 |
+=======+========+
| Alice | 90000 |
| Carol | 80000 |
+-------+--------+
The pattern is always the same three steps: wrap the native DataFrame with nw.from_native(), apply your transformations using Narwhals expressions, then call nw.to_native() to hand back a DataFrame in whichever format the caller originally provided. The function has no idea what backend it is working with — and it does not need to.
The sections below cover the full expression API, how to use Narwhals inside library functions, groupby and aggregation, schema inspection, and a real-world pipeline that processes sales data from either backend. Read on for the complete picture.
What Is Narwhals and Why Use It?
Narwhals is a thin compatibility layer for the Python DataFrame ecosystem. Think of it as a universal remote control — different devices (pandas, polars, modin), one set of buttons. Under the hood it translates each Narwhals expression into the equivalent native call on whichever backend is in use. When you write nw.col("price").mean(), Narwhals emits df["price"].mean() for pandas and pl.col("price").mean() for polars. The translation is handled for you.
The primary use case is writing libraries and utilities that accept a DataFrame from a caller you do not control. If you write a data validation function, a feature-engineering helper, or a report generator, you probably do not want to force all your users onto a single DataFrame library. Narwhals lets you accept whatever they have and return the same type back.
| Feature | Narwhals | pandas only | polars only |
|---|---|---|---|
| Works with pandas | Yes | Yes | No |
| Works with polars | Yes | No | Yes |
| Works with modin/cuDF | Yes | Partial | No |
| Unified expression API | Yes | No | No |
| Zero mandatory deps | Yes | No | No |
| Returns caller’s type | Yes | N/A | N/A |
Narwhals is not a replacement for polars or pandas — it is a wrapper you use at the boundaries of your code where the input DataFrame type is unknown. Your internal data-science scripts where you know the type should still use pandas or polars directly. Narwhals earns its place in shared utilities, open-source libraries, and pipelines that need to support multiple backends without duplication.
Installing Narwhals
Narwhals is on PyPI. It has no mandatory runtime dependencies — the only packages it imports are the ones your caller already has installed.
# Install narwhals
pip install narwhals
# Install your backends of choice (narwhals works with whichever you have)
pip install pandas polars
Output:
Successfully installed narwhals-1.x.x
Verify the install and check which backends are detectable:
# check_narwhals.py
import narwhals as nw
print("Narwhals version:", nw.__version__)
# Check available backends
import importlib
for backend in ["pandas", "polars", "modin.pandas", "cudf"]:
available = importlib.util.find_spec(backend.split(".")[0]) is not None
print(f" {backend}: {'available' if available else 'not installed'}")
Output:
Narwhals version: 1.x.x
pandas: available
polars: available
modin.pandas: not installed
cudf: not installed
You need at least one DataFrame backend installed. Narwhals itself imports in milliseconds and adds no overhead to import time for packages that depend on it.
Wrapping and Unwrapping DataFrames
Every Narwhals operation starts with nw.from_native() and usually ends with nw.to_native(). Understanding these two functions is the foundation of the entire library.
Converting In: nw.from_native()
nw.from_native() wraps any supported native DataFrame or Series in a Narwhals proxy object. The proxy exposes a consistent API regardless of what is underneath. You can pass eager_only=True to restrict the function to eager DataFrames (pandas, polars eager) and get better type hints.
# wrapping.py
import narwhals as nw
import pandas as pd
import polars as pl
# Wrap a pandas DataFrame
pdf = pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
ndf_pd = nw.from_native(pdf, eager_only=True)
print(type(ndf_pd)) # narwhals DataFrame
print(ndf_pd.schema) # {'x': Int64, 'y': Int64}
# Wrap a polars DataFrame -- same API
plf = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
ndf_pl = nw.from_native(plf, eager_only=True)
print(type(ndf_pl)) # narwhals DataFrame
print(ndf_pl.schema) # {'x': Int64, 'y': Int64}
Output:
<class 'narwhals.dataframe.DataFrame'>
{'x': Int64, 'y': Int64}
<class 'narwhals.dataframe.DataFrame'>
{'x': Int64, 'y': Int64}
Both DataFrames produce the same Narwhals type with the same schema representation. From this point on, any operation you perform on ndf_pd and ndf_pl uses the same Narwhals method calls — no if isinstance branching needed.
Converting Out: nw.to_native()
nw.to_native() unwraps the Narwhals proxy and returns the underlying native DataFrame. The returned type matches whatever was passed in originally — if you wrapped a pandas DataFrame, you get a pandas DataFrame back; if you wrapped polars, you get polars back. This is how you preserve the caller’s type through a transformation pipeline.
# unwrapping.py
import narwhals as nw
import pandas as pd
import polars as pl
def double_x(df_native):
df = nw.from_native(df_native, eager_only=True)
result = df.with_columns((nw.col("x") * 2).alias("x_doubled"))
return nw.to_native(result)
pdf = pd.DataFrame({"x": [1, 2, 3]})
plf = pl.DataFrame({"x": [1, 2, 3]})
pd_result = double_x(pdf)
pl_result = double_x(plf)
print(type(pd_result)) # pandas DataFrame
print(type(pl_result)) # polars DataFrame
print(pd_result)
print(pl_result)
Output:
<class 'pandas.core.frame.DataFrame'>
<class 'polars.dataframe.frame.DataFrame'>
x x_doubled
0 1 2
1 2 4
2 3 6
shape: (3, 2)
+---------+-----------+
| x | x_doubled |
| i64 | i64 |
+=========+===========+
| 1 | 2 |
| 2 | 4 |
| 3 | 6 |
+---------+-----------+
The function returns whatever type it received. Callers do not need to convert their data before calling your function, and they do not need to convert the result afterward — it comes back in the form they expected.
Using the Narwhals Expression API
The Narwhals expression system works like polars expressions: you build a lazy description of a computation using nw.col(), and the DataFrame executes it when you call a method like filter(), select(), or with_columns(). This is more composable than pandas’ method chains and translates cleanly to both backends.
Filtering and Selecting Columns
Use df.filter() to keep rows matching a condition, and df.select() to choose and rename columns. Both accept Narwhals expressions built with nw.col().
# filter_select.py
import narwhals as nw
import pandas as pd
data = {
"product": ["Laptop", "Monitor", "Keyboard", "Mouse", "Webcam"],
"price": [1200, 450, 80, 35, 120],
"units_sold": [15, 30, 200, 350, 80],
}
df = nw.from_native(pd.DataFrame(data), eager_only=True)
# Filter: only products that sold more than 50 units
popular = df.filter(nw.col("units_sold") > 50)
print("Popular products:")
print(nw.to_native(popular))
# Select: build a revenue column and return two columns
revenue = df.select(
nw.col("product"),
(nw.col("price") * nw.col("units_sold")).alias("revenue"),
)
print("\nRevenue by product:")
print(nw.to_native(revenue))
Output:
Popular products:
product price units_sold
1 Monitor 450 30
2 Keyboard 80 200
4 Webcam 120 80
Revenue by product:
product revenue
0 Laptop 18000
1 Monitor 13500
2 Keyboard 16000
3 Mouse 12250
4 Webcam 9600
Notice that the filter and select expressions read identically regardless of backend. The same code works on a polars DataFrame with no changes. Narwhals translates nw.col("units_sold") > 50 to the correct native expression at call time.
Adding and Transforming Columns
df.with_columns() adds new columns or replaces existing ones without dropping the rest of the DataFrame. It accepts a list of Narwhals expressions and is the equivalent of pandas’ df.assign() or polars’ df.with_columns().
# with_columns.py
import narwhals as nw
import polars as pl
df = nw.from_native(
pl.DataFrame({
"name": ["Alice", "Bob", "Carol", "Dave"],
"score": [82, 67, 91, 74],
"attempts": [3, 5, 2, 4],
}),
eager_only=True,
)
enriched = df.with_columns(
(nw.col("score") / nw.col("attempts")).round(1).alias("score_per_attempt"),
(nw.col("score") >= 80).alias("passed"),
)
print(nw.to_native(enriched))
Output:
shape: (4, 5)
+-------+-------+----------+-------------------+--------+
| name | score | attempts | score_per_attempt | passed |
| str | i64 | i64 | f64 | bool |
+=======+=======+==========+===================+========+
| Alice | 82 | 3 | 27.3 | true |
| Bob | 67 | 5 | 13.4 | false |
| Carol | 91 | 2 | 45.5 | true |
| Dave | 74 | 4 | 18.5 | false |
+-------+-------+----------+-------------------+--------+
The .round(1) call chains directly onto the expression — Narwhals expression chaining works the same way in both backends. You can chain as many transformations as you need before passing the expression to with_columns().
GroupBy and Aggregation
GroupBy operations are where pandas and polars diverge most sharply in their native APIs. Narwhals unifies them with a group_by().agg() pattern that works identically on both backends and produces the same result shape.
# groupby_agg.py
import narwhals as nw
import pandas as pd
sales_data = {
"region": ["North", "South", "North", "East", "South", "East", "North"],
"category": ["Electronics", "Clothing", "Electronics", "Clothing", "Electronics", "Electronics", "Clothing"],
"revenue": [4200, 1800, 3100, 2400, 2900, 3600, 1500],
"units": [14, 60, 10, 80, 29, 36, 50],
}
def regional_summary(df_native):
df = nw.from_native(df_native, eager_only=True)
summary = (
df.group_by("region")
.agg(
nw.col("revenue").sum().alias("total_revenue"),
nw.col("units").sum().alias("total_units"),
nw.col("revenue").mean().round(0).alias("avg_deal_size"),
)
.sort("total_revenue", descending=True)
)
return nw.to_native(summary)
# Test with pandas
result = regional_summary(pd.DataFrame(sales_data))
print(result)
Output:
region total_revenue total_units avg_deal_size
0 East 6000 116 3000.0
1 North 8800 74 2933.0
2 South 4700 89 2350.0
The same function called with a polars DataFrame produces identical data (though polars formats the output differently). The key insight is that group_by().agg() is a Narwhals pattern — your code never touches df.groupby() (pandas) or df.group_by() (polars) directly, so there is no divergence to manage.
Schema Inspection and Type Handling
Narwhals exposes a unified schema that normalizes type names across backends. This is useful when you need to validate that a DataFrame has the expected columns and types before processing it.
# schema_check.py
import narwhals as nw
import pandas as pd
import polars as pl
def validate_and_describe(df_native):
df = nw.from_native(df_native, eager_only=True)
schema = df.schema
print("Schema:", schema)
print("Columns:", df.columns)
print("Shape:", df.shape)
# Check for required columns
required = {"name", "age", "salary"}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
# Narwhals dtype comparison works across backends
for col, dtype in schema.items():
print(f" {col}: {dtype} (numeric={dtype.is_numeric()})")
pandas_df = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25], "salary": [75000, 62000]})
polars_df = pl.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25], "salary": [75000, 62000]})
print("--- Pandas ---")
validate_and_describe(pandas_df)
print("\n--- Polars ---")
validate_and_describe(polars_df)
Output:
--- Pandas ---
Schema: {'name': String, 'age': Int64, 'salary': Int64}
Columns: ['name', 'age', 'salary']
Shape: (2, 3)
name: String (numeric=False)
age: Int64 (numeric=True)
salary: Int64 (numeric=True)
--- Polars ---
Schema: {'name': String, 'age': Int64, 'salary': Int64}
Columns: ['name', 'age', 'salary']
Shape: (2, 3)
name: String (numeric=False)
age: Int64 (numeric=True)
salary: Int64 (numeric=True)
The schemas are identical even though the underlying backends store and display types differently. dtype.is_numeric(), dtype.is_temporal(), and similar methods work consistently, so you can write type-based validation logic that runs on any backend without a lookup table of backend-specific type names.
Writing Backend-Agnostic Library Functions
The most powerful use of Narwhals is writing utility functions that you can publish in a library or share across a team, where callers may use different DataFrame libraries. The decorator @nw.narwhalify handles the wrap/unwrap automatically — no need to call from_native and to_native manually.
# library_utils.py
import narwhals as nw
@nw.narwhalify
def normalize_scores(df, score_col="score"):
"""Normalize a score column to 0-1 range. Works on any Narwhals-supported DataFrame."""
col_min = df[score_col].min()
col_max = df[score_col].max()
return df.with_columns(
((nw.col(score_col) - col_min) / (col_max - col_min)).alias(f"{score_col}_normalized")
)
@nw.narwhalify
def drop_nulls_and_report(df):
"""Drop null rows and print how many were removed."""
original_len = len(df)
cleaned = df.drop_nulls()
dropped = original_len - len(cleaned)
if dropped > 0:
print(f"Dropped {dropped} rows with null values ({dropped/original_len:.1%} of data)")
return cleaned
# Test with both backends
import pandas as pd
import polars as pl
pdf = pd.DataFrame({"student": ["Alice", "Bob", "Carol"], "score": [78, 95, 61]})
plf = pl.DataFrame({"student": ["Alice", "Bob", "Carol"], "score": [78, 95, 61]})
print("Pandas result:")
print(normalize_scores(pdf))
print("\nPolars result:")
print(normalize_scores(plf))
Output:
Pandas result:
student score score_normalized
0 Alice 78 0.500000
1 Bob 95 1.000000
2 Carol 61 0.000000
Polars result:
shape: (3, 3)
+---------+-------+------------------+
| student | score | score_normalized |
| str | i64 | f64 |
+=========+=======+==================+
| Alice | 78 | 0.5 |
| Bob | 95 | 1.0 |
| Carol | 61 | 0.0 |
+---------+-------+------------------+
The @nw.narwhalify decorator wraps all DataFrame and Series arguments automatically when the function is called, then unwraps the return value back to the caller’s native type. This is the pattern to use when publishing functions in a shared library — it is the most Pythonic way to expose a Narwhals-powered API to callers who may not know or care that Narwhals is involved.
Real-Life Example: A Backend-Agnostic Sales Report Generator
This project builds a complete sales report generator that accepts any supported DataFrame, computes revenue metrics by region and category, flags underperformers, and returns a formatted summary. It is designed to be dropped into any project as a standalone utility.
# sales_report.py
import narwhals as nw
from typing import Any
@nw.narwhalify
def generate_sales_report(df, revenue_col="revenue", group_col="region", threshold_pct=0.8):
"""
Generate a sales performance report grouped by region.
Works with any Narwhals-supported DataFrame (pandas, polars, modin, etc.).
Args:
df: Any supported DataFrame with at minimum 'revenue' and 'region' columns.
revenue_col: Name of the revenue column.
group_col: Column to group by (default: 'region').
threshold_pct: Groups below this fraction of the mean are flagged as underperformers.
Returns:
DataFrame with group totals, averages, deal counts, and performance flags.
Return type matches the input type.
"""
# Step 1: Group and aggregate
summary = (
df.group_by(group_col)
.agg(
nw.col(revenue_col).sum().alias("total_revenue"),
nw.col(revenue_col).mean().round(0).alias("avg_deal"),
nw.len().alias("deal_count"),
)
.sort("total_revenue", descending=True)
)
# Step 2: Compute overall mean for flagging
mean_rev = summary["total_revenue"].mean()
# Step 3: Add performance flag
summary = summary.with_columns(
(nw.col("total_revenue") < mean_rev * threshold_pct).alias("underperforming")
)
return summary
# --- Demo with pandas ---
import pandas as pd
import polars as pl
sample_data = {
"region": ["North", "South", "North", "East", "South", "East", "West", "North", "West"],
"category": ["SaaS", "Hardware", "SaaS", "Services", "Hardware", "SaaS", "Services", "Hardware", "SaaS"],
"revenue": [12000, 8500, 9800, 15000, 7200, 11000, 4300, 6100, 9700],
"sales_rep": ["Ana", "Ben", "Ana", "Cara", "Ben", "Cara", "Dan", "Ana", "Dan"],
}
print("=== Pandas Backend ===")
pandas_report = generate_sales_report(pd.DataFrame(sample_data))
print(pandas_report)
print(f"Return type: {type(pandas_report).__name__}\n")
print("=== Polars Backend ===")
polars_report = generate_sales_report(pl.DataFrame(sample_data))
print(polars_report)
print(f"Return type: {type(polars_report).__name__}")
Output:
=== Pandas Backend ===
region total_revenue avg_deal deal_count underperforming
0 East 26000 13000.0 2 False
1 North 27900 9300.0 3 False
2 South 15700 7850.0 2 True
3 West 14000 7000.0 2 True
Return type: DataFrame
=== Polars Backend ===
shape: (4, 5)
+--------+---------------+----------+------------+-----------------+
| region | total_revenue | avg_deal | deal_count | underperforming |
| str | i64 | f64 | u32 | bool |
+========+===============+==========+============+=================+
| East | 26000 | 13000.0 | 2 | false |
| North | 27900 | 9300.0 | 3 | false |
| South | 15700 | 7850.0 | 2 | true |
| West | 14000 | 7000.0 | 2 | true |
+--------+---------------+----------+------------+-----------------+
Return type: DataFrame
Both backends produce the same data. The function is self-contained -- drop it into any project and it works regardless of which DataFrame library the project uses. To extend this project, add a category groupby dimension, compute month-over-month growth by joining with historical data, or build an HTML report using the aggregated summary. Because the return type matches the input, the result slots naturally into any downstream pipeline the caller already has.
Frequently Asked Questions
Does Narwhals support polars LazyFrame?
Yes. nw.from_native() wraps polars LazyFrame into a Narwhals LazyFrame, and the expression API works the same way. The main difference is that you cannot inspect rows or compute values until you call .collect() -- just like native polars lazy mode. When you call nw.to_native() on a Narwhals LazyFrame, you get a polars LazyFrame back (not a collected DataFrame). If you need the result immediately, call nw.to_native(result.collect()). Use eager_only=True in from_native() to raise an error if a LazyFrame is passed, which is useful for functions that need immediate results.
What do I do if Narwhals doesn't support an operation I need?
Narwhals covers the most common DataFrame operations (filter, select, with_columns, group_by, sort, join, drop_nulls, rename, schema inspection), but it does not wrap every method of every backend. If you need a backend-specific operation, you can always call nw.to_native(df) to drop back to the native DataFrame and use the native API directly. The typical pattern is: do as much as possible in Narwhals, then escape to native only for the specific operation that Narwhals does not cover. You can always wrap the result again with nw.from_native() to continue with the unified API afterward.
Does Narwhals add overhead?
The overhead is minimal -- Narwhals is a thin dispatch layer that translates method calls, not a data-processing engine. Each Narwhals expression compiles to a native expression at call time, and the underlying backend does the actual computation. For large datasets the dominant cost is the backend computation, not the Narwhals translation. Benchmarks from the Narwhals project show overhead in the microseconds range for typical operations. If you are processing tens of millions of rows, use polars directly where performance is the primary concern and Narwhals only at the interoperability boundaries.
Can I use Narwhals with Series, not just DataFrames?
Yes. nw.from_native(series, series_only=True) wraps a pandas or polars Series into a Narwhals Series with a unified API. You can use arithmetic, string methods (.str.to_lowercase(), .str.starts_with()), and datetime accessors (.dt.year(), .dt.month()) the same way across backends. The @nw.narwhalify decorator also handles Series arguments automatically when it detects them. This is useful for column-level utility functions that only need to transform a single column.
How does join work in Narwhals?
Narwhals supports df.join(other, on="key", how="inner") for inner, left, and anti joins. The syntax mirrors polars -- pass on for same-name keys or left_on / right_on for different-named keys. The how parameter accepts "inner", "left", and "anti". Cross joins and full outer joins are not universally supported across all backends and are currently out of scope for Narwhals. For those cases, escape to native as described in the previous FAQ.
When should I NOT use Narwhals?
Avoid Narwhals in three scenarios: (1) single-backend applications where you fully control the input type -- just use pandas or polars directly; (2) performance-critical inner loops where even microsecond overhead compounds -- use native polars there; (3) operations heavily relying on pandas-specific features like MultiIndex, Panel data, or in-place mutation -- Narwhals does not expose these. Narwhals is a tool for interoperability at function boundaries, not a replacement for mastering the individual backends.
Conclusion
Narwhals removes the choice between "support pandas" and "support polars" by making it unnecessary. The pattern is simple: nw.from_native() at the entry point of your function, Narwhals expressions for all your logic, and nw.to_native() at the exit. Or use @nw.narwhalify to handle the wrapping automatically. The unified schema API, expression system, and group_by().agg() pattern cover the vast majority of data transformation work you need to do at library boundaries.
The best next step is to take an existing utility function in your codebase that accepts a DataFrame and add Narwhals support to it. Pick a function with a clear input and output, wrap it with @nw.narwhalify, replace pandas-specific calls with Narwhals expressions, and run your tests against both backends. The migration is usually straightforward for filter, select, group_by, and with_columns operations. For the official documentation, the API reference, and the list of fully supported operations across backends, see narwhals-dev.github.io/narwhals/. The project is actively maintained and expanding its operation coverage with each release.
Related Articles
- How To Use Polars for Fast DataFrame Operations in Python
- How To Use Python Box for Dot-Notation Dict Access
- How To Use PyArrow for Parquet File Processing in Python
- How To Use Python Dask for Parallel Data Processing
- How To Use Python more-itertools for Advanced Iteration
- How To Use Python DuckDB for Analytical SQL
- How To Use Python PyGame for 2D Game Development
Further Reading: For more details, see the Python datetime module documentation.
Pro Tips for Working with Public Holidays in Python
1. Cache Holiday Data to Avoid Repeated API Calls
If you are using the Calendarific API, cache the results locally instead of calling the API every time you check a date. Holiday lists for a given country and year rarely change. Save the API response to a JSON file and only refresh it when the year changes. This reduces API usage and makes your application faster.
# cache_holidays.py
import json
import os
from datetime import date
CACHE_FILE = "holidays_cache.json"
def get_cached_holidays(country, year):
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, "r") as f:
cache = json.load(f)
key = f"{country}_{year}"
if key in cache:
print(f"Using cached holidays for {country} {year}")
return cache[key]
return None
def save_to_cache(country, year, holidays):
cache = {}
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, "r") as f:
cache = json.load(f)
cache[f"{country}_{year}"] = holidays
with open(CACHE_FILE, "w") as f:
json.dump(cache, f, indent=2)
print(f"Cached {len(holidays)} holidays for {country} {year}")
Output:
Cached 11 holidays for US 2026
Using cached holidays for US 2026
2. Calculate Business Days Excluding Holidays
One of the most common real-world uses of holiday detection is calculating business days. Combine the holidays library with Python’s datetime to count only working days between two dates, excluding weekends and public holidays. This is essential for shipping estimates, SLA calculations, and payroll processing.
# business_days.py
import holidays
from datetime import date, timedelta
def business_days_between(start, end, country="US"):
us_holidays = holidays.country_holidays(country)
count = 0
current = start
while current <= end:
if current.weekday() < 5 and current not in us_holidays:
count += 1
current += timedelta(days=1)
return count
start = date(2026, 12, 20)
end = date(2026, 12, 31)
days = business_days_between(start, end)
print(f"Business days from {start} to {end}: {days}")
Output:
Business days from 2026-12-20 to 2026-12-31: 7
3. Handle Multiple Countries for International Apps
If your application serves users in different countries, check holidays for each user's country rather than assuming a single country. The holidays library supports 100+ countries. Store each user's country code and pass it when checking holidays. Remember that some countries have regional holidays too -- for example, different states in Australia or provinces in Canada have different public holidays.
4. Build a Holiday-Aware Scheduler
Many applications need to skip processing on holidays. Instead of checking manually every time, create a decorator that wraps scheduled tasks and automatically skips execution on public holidays. This is useful for automated reports, email campaigns, and batch processing jobs that should only run on business days.
# holiday_aware_scheduler.py
import holidays
from datetime import date
from functools import wraps
def skip_on_holidays(country="US"):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
today = date.today()
if today in holidays.country_holidays(country):
name = holidays.country_holidays(country).get(today)
print(f"Skipping {func.__name__}: today is {name}")
return None
return func(*args, **kwargs)
return wrapper
return decorator
@skip_on_holidays("US")
def send_daily_report():
print("Sending daily report...")
return "Report sent"
result = send_daily_report()
print(f"Result: {result}")
Output (on a regular business day):
Sending daily report...
Result: Report sent
5. Display Upcoming Holidays for Better UX
Show your users which holidays are coming up so they can plan ahead. This is valuable for project management tools, delivery estimate pages, and HR applications. Sort the holiday list by date and filter for upcoming dates only to give users a clear view of the next few holidays.
Frequently Asked Questions
How do I check if a date is a public holiday in Python?
Use the holidays library: install it with pip install holidays, then check with date in holidays.country_holidays('US'). It returns True if the date is a recognized public holiday for that country.
What countries does the Python holidays library support?
The holidays library supports over 100 countries and their subdivisions. Major countries include the US, UK, Canada, Australia, Germany, France, India, and many more. Use holidays.list_supported_countries() to see the complete list.
Can I add custom holidays to the holidays library?
Yes. Create a custom holiday class inheriting from the country class, or use the append() method to add individual dates. You can also create entirely custom holiday calendars for company-specific or regional holidays.
How do I get the name of a holiday for a specific date?
Access the holiday name with holidays.country_holidays('US').get(date), which returns the holiday name as a string, or None if it is not a holiday. You can also iterate over the holidays object to list all holidays in a year.
Is the holidays library useful for business day calculations?
Yes. Combine it with numpy.busday_count() or pandas.bdate_range() to calculate working days excluding public holidays. This is useful for project management, payroll calculations, and delivery date estimation.
Related Articles
- How To Use Python litellm for Multi-Model LLM Integration
- How To Use Python instructor for Structured LLM Outputs
- How To Use Python LlamaIndex for Document Q&A
- How To Use PyJWT for JSON Web Tokens in Python
- How To Use Python cryptography for Encryption and Decryption
- How To Build a Python gRPC Service with grpcio
Continue Learning Python
Tutorials you might also find useful: