Skip to content

Commit 78ddb7c

Browse files
committed
Merge remote-tracking branch 'origin/main' into dbi-609
2 parents 5569844 + c49bc22 commit 78ddb7c

306 files changed

Lines changed: 3509 additions & 10921 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/check-build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ jobs:
3737
uses: actions/setup-node@v6
3838
with:
3939
node-version: 20
40-
- name: Install markdownlint-cli2
40+
- name: Install markdown lint dependencies
4141
if: matrix.check_type == 'md-lint'
42-
run: yarn add -D markdownlint-cli2
42+
run: yarn install --frozen-lockfile
4343

4444
# Run the checks here
4545
- name: Run spellcheck

docs/chdb/api/python.md

Lines changed: 266 additions & 21 deletions
Large diffs are not rendered by default.

docs/chdb/guides/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ in the table of contents, please edit the frontmatter of the files directly.
2727
| [Migration from pandas](/chdb/guides/migration-from-pandas) | Step-by-step guide to migrate from pandas to DataStore |
2828
| [Pandas cookbook](/chdb/guides/pandas-cookbook) | Common pandas patterns and their DataStore equivalents |
2929
| [Performance guide](/chdb/guides/pandas-performance) | Performance optimization tips for DataStore vs pandas |
30+
| [Python user-defined functions (UDF)](/chdb/guides/python-udf) | Create native Python UDFs in chDB with typed arguments, NULL handling, and exception control. |
3031
| [SQL for pandas users](/chdb/guides/pandas-to-sql) | Understanding how pandas operations map to SQL in DataStore |
3132
| [Using a clickhouse-local database](/chdb/guides/clickhouse-local) | Learn how to use a clickhouse-local database with chDB |
3233
<!--AUTOGENERATED_END-->

docs/chdb/guides/python-udf.md

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
---
2+
title: 'Python user-defined functions (UDF)'
3+
sidebar_label: 'Python UDF'
4+
slug: /chdb/guides/python-udf
5+
description: 'Create native Python UDFs in chDB with typed arguments, NULL handling, and exception control.'
6+
keywords: ['chdb', 'udf', 'python', 'user-defined function']
7+
doc_type: 'guide'
8+
---
9+
10+
# Python user-defined functions (UDF)
11+
12+
chDB allows you to register Python functions as SQL-callable UDFs. These run natively in-process — no subprocess spawning, no serialization overhead. Functions are type-safe, support automatic type inference from Python annotations, and offer configurable NULL and exception handling.
13+
14+
## Quick start {#quick-start}
15+
16+
```python
17+
from chdb import query, func
18+
from chdb.sqltypes import INT64
19+
20+
@func([INT64, INT64], INT64)
21+
def add(a, b):
22+
return a + b
23+
24+
result = query("SELECT add(2, 3)")
25+
print(result) # 5
26+
```
27+
28+
:::note
29+
Examples in this guide run `query()` with the default CSV output format. Inline comments show the logical result values; the raw output prints `NULL` as `\N` and applies CSV quoting to string and date values (for example `"Hello, world!"`).
30+
:::
31+
32+
## Registration methods {#registration-methods}
33+
34+
### `@func` decorator {#func-decorator}
35+
36+
The simplest way to register a UDF. The function's `__name__` becomes the SQL function name.
37+
38+
```python
39+
from chdb import func
40+
from chdb.sqltypes import INT64, STRING
41+
42+
# Explicit types
43+
@func([INT64, INT64], INT64)
44+
def add(a, b):
45+
return a + b
46+
47+
# Types inferred from annotations
48+
@func()
49+
def multiply(a: int, b: int) -> int:
50+
return a * b
51+
52+
# Explicit return_type, arg_types inferred from annotations
53+
@func(return_type=STRING)
54+
def greet(name: str):
55+
return f"Hello, {name}!"
56+
```
57+
58+
The decorated function remains callable as normal Python:
59+
60+
```python
61+
add(2, 3) # 5 (Python call)
62+
query("SELECT add(2, 3)") # 5 (SQL call)
63+
```
64+
65+
### `create_function` {#create-function}
66+
67+
Register any callable (lambda, function, method) with an explicit name:
68+
69+
```python
70+
from chdb import create_function, query
71+
from chdb.sqltypes import INT64, STRING
72+
73+
create_function("strlen", len, arg_types=[STRING], return_type=INT64)
74+
query("SELECT strlen('hello')") # 5
75+
76+
create_function("double", lambda x: x * 2, arg_types=[INT64], return_type=INT64)
77+
query("SELECT double(21)") # 42
78+
```
79+
80+
### `drop_function` {#drop-function}
81+
82+
Remove a registered UDF. Dropping a name that is not registered does nothing, so it is safe to call unconditionally:
83+
84+
```python
85+
from chdb import drop_function
86+
87+
drop_function("strlen")
88+
# query("SELECT strlen('hello')") # Error: function not found
89+
```
90+
91+
:::note
92+
Registering a name that is already registered raises an error — UDFs are not silently replaced. Call `drop_function(name)` first to re-register a function, for example when re-running a notebook cell.
93+
:::
94+
95+
## Type system {#type-system}
96+
97+
### Available types {#available-types}
98+
99+
All types are importable from `chdb.sqltypes`:
100+
101+
```python
102+
from chdb.sqltypes import (
103+
# Boolean
104+
BOOL,
105+
# Signed integers
106+
INT8, INT16, INT32, INT64, INT128, INT256,
107+
# Unsigned integers
108+
UINT8, UINT16, UINT32, UINT64, UINT128, UINT256,
109+
# Floating point
110+
FLOAT32, FLOAT64,
111+
# String
112+
STRING,
113+
# Date and time
114+
DATE, DATE32, DATETIME, DATETIME64,
115+
)
116+
```
117+
118+
### Specifying types {#specifying-types}
119+
120+
Types can be provided in four ways:
121+
122+
| Method | Example | Description |
123+
|--------|---------|-------------|
124+
| `ChdbType` constant | `INT64`, `STRING` | Imported from `chdb.sqltypes` |
125+
| ClickHouse type string | `"Int64"`, `"String"` | Standard ClickHouse type names |
126+
| Parameterized string | `"DateTime('UTC')"`, `"DateTime64(6)"` | For types with parameters |
127+
| Python type | `int`, `str`, `float` | Passed directly in `arg_types`/`return_type`, or used as type annotations in the function signature |
128+
129+
```python
130+
from chdb import create_function, func
131+
from chdb.sqltypes import INT64
132+
133+
# All equivalent:
134+
create_function("f1", lambda x: x * 2, arg_types=[INT64], return_type=INT64)
135+
create_function("f2", lambda x: x * 2, arg_types=["Int64"], return_type="Int64")
136+
create_function("f3", lambda x: x * 2, arg_types=[int], return_type=int)
137+
138+
@func()
139+
def f4(x: int) -> int:
140+
return x * 2
141+
```
142+
143+
### Automatic type inference {#automatic-type-inference}
144+
145+
When `arg_types` or `return_type` is omitted, chDB infers types from Python type annotations:
146+
147+
| Python Type | ClickHouse Type |
148+
|-------------|-----------------|
149+
| `bool` | `Bool` |
150+
| `int` | `Int64` |
151+
| `float` | `Float64` |
152+
| `str` | `String` |
153+
| `bytes` | `String` |
154+
| `bytearray` | `String` |
155+
| `datetime.date` | `Date` |
156+
| `datetime.datetime` | `DateTime64(6)` |
157+
158+
```python
159+
@func()
160+
def process(name: str, age: int) -> str:
161+
return f"{name} is {age} years old"
162+
163+
# Equivalent to:
164+
# @func([STRING, INT64], STRING)
165+
```
166+
167+
:::note
168+
If `arg_types` is provided explicitly, it must cover **all** parameters — partial explicit + partial inferred is not supported. This applies to both `create_function` and the `@func` decorator: either specify types for all parameters, or omit them entirely and let chDB infer from annotations.
169+
:::
170+
171+
A return type is always required: if `return_type` is omitted and the function has no return annotation, registration fails. Argument types, by contrast, are optional — a parameter with neither an explicit type nor an annotation accepts any supported input type dynamically.
172+
173+
## NULL handling {#null-handling}
174+
175+
The `on_null` parameter controls behavior when any input argument is NULL.
176+
177+
| Value | Behavior |
178+
|-------|----------|
179+
| `"skip"` (default) | Return NULL immediately without calling the function |
180+
| `"pass"` | Convert NULL to Python `None` and call the function normally |
181+
182+
You can also use the enum: `chdb.NullHandling.SKIP` / `chdb.NullHandling.PASS`.
183+
184+
### Example: default (skip) {#null-skip}
185+
186+
```python
187+
@func(return_type="Int64")
188+
def increment(x: int) -> int:
189+
return x + 1
190+
191+
query("SELECT increment(NULL)") # NULL
192+
query("SELECT increment(5)") # 6
193+
```
194+
195+
### Example: pass NULL as `None` {#null-pass}
196+
197+
```python
198+
@func(return_type="Int64", on_null="pass")
199+
def null_to_zero(x):
200+
return 0 if x is None else x + 1
201+
202+
query("SELECT null_to_zero(NULL)") # 0
203+
query("SELECT null_to_zero(5)") # 6
204+
```
205+
206+
### Example: multiple arguments {#null-multiple-args}
207+
208+
```python
209+
@func(arg_types=["Int64", "Int64"], return_type="Int64", on_null="pass")
210+
def add_or_zero(a, b):
211+
return (a or 0) + (b or 0)
212+
213+
query("SELECT add_or_zero(NULL, 5)") # 5
214+
query("SELECT add_or_zero(NULL, NULL)") # 0
215+
query("SELECT add_or_zero(3, 7)") # 10
216+
```
217+
218+
## Exception handling {#exception-handling}
219+
220+
The `on_error` parameter controls behavior when the Python function raises an exception.
221+
222+
| Value | Behavior |
223+
|-------|----------|
224+
| `"propagate"` (default) | Raise the exception as a SQL error |
225+
| `"ignore"` | Catch the exception and return NULL for that row |
226+
227+
You can also use the enum: `chdb.ExceptionHandling.PROPAGATE` / `chdb.ExceptionHandling.IGNORE`.
228+
229+
### Example: default (propagate) {#exception-propagate}
230+
231+
```python
232+
@func(arg_types=["Int64", "Int64"], return_type="Int64")
233+
def divide(a, b):
234+
return a // b
235+
236+
query("SELECT divide(10, 2)") # 5
237+
query("SELECT divide(1, 0)") # Error: ZeroDivisionError
238+
```
239+
240+
### Example: ignore errors {#exception-ignore}
241+
242+
```python
243+
@func(arg_types=["Int64", "Int64"], return_type="Int64", on_error="ignore")
244+
def safe_divide(a, b):
245+
return a // b
246+
247+
query("SELECT safe_divide(10, 2)") # 5
248+
query("SELECT safe_divide(1, 0)") # NULL
249+
```
250+
251+
## Combining NULL and exception handling {#combining-null-and-exception}
252+
253+
The `on_null` and `on_error` options can be combined:
254+
255+
| on_null | on_error | NULL input | Exception |
256+
|---------|----------|------------|-----------|
257+
| `"skip"` | `"propagate"` | Return NULL | Raise error |
258+
| `"skip"` | `"ignore"` | Return NULL | Return NULL |
259+
| `"pass"` | `"propagate"` | Call with `None` | Raise error |
260+
| `"pass"` | `"ignore"` | Call with `None` | Return NULL |
261+
262+
```python
263+
@func(
264+
arg_types=["Int64", "Int64"],
265+
return_type="Int64",
266+
on_null="pass",
267+
on_error="ignore",
268+
)
269+
def robust_divide(a, b):
270+
if a is None or b is None:
271+
return -1
272+
return a // b
273+
274+
query("SELECT robust_divide(10, 2)") # 5
275+
query("SELECT robust_divide(NULL, 2)") # -1
276+
query("SELECT robust_divide(1, 0)") # NULL (exception caught)
277+
```
278+
279+
## DateTime and timezone support {#datetime-and-timezone}
280+
281+
UDFs fully support date and time types with timezone awareness.
282+
283+
### Date types {#date-types}
284+
285+
```python
286+
from datetime import date, timedelta
287+
288+
@func()
289+
def next_day(d: date) -> date:
290+
return d + timedelta(days=1)
291+
292+
@func()
293+
def get_year(d: date) -> int:
294+
return d.year
295+
296+
query("SELECT next_day(toDate('2024-06-15'))") # 2024-06-16
297+
query("SELECT get_year(toDate('2024-06-15'))") # 2024
298+
```
299+
300+
### DateTime with timezones {#datetime-with-timezones}
301+
302+
```python
303+
from datetime import timedelta
304+
305+
@func(arg_types=["DateTime('UTC')"], return_type="DateTime('UTC')")
306+
def add_one_hour(dt):
307+
return dt + timedelta(hours=1)
308+
309+
query("SELECT add_one_hour(toDateTime('2024-01-01 12:00:00', 'UTC'))") # 2024-01-01 13:00:00
310+
```
311+
312+
### DateTime64 (high precision) {#datetime64}
313+
314+
`DATETIME64` defaults to scale 6 (microseconds):
315+
316+
```python
317+
from datetime import timedelta
318+
319+
@func(arg_types=["DateTime64(6, 'UTC')"], return_type="DateTime64(6, 'UTC')")
320+
def add_microsecond(dt):
321+
return dt + timedelta(microseconds=1)
322+
323+
query("SELECT add_microsecond(toDateTime64('2024-01-01 12:00:00.000000', 6, 'UTC'))") # 2024-01-01 12:00:00.000001
324+
```
325+
326+
:::note
327+
- Input `DateTime`/`DateTime64` values carry timezone info from ClickHouse
328+
- Output `datetime` objects preserve timezone info
329+
- Timezone conversion is handled automatically
330+
:::
331+
332+
## Using UDFs with sessions {#using-udfs-with-sessions}
333+
334+
UDFs are registered globally and available across all sessions in the same process:
335+
336+
```python
337+
from chdb import session as chs, func
338+
from chdb.sqltypes import INT64
339+
340+
@func([INT64], INT64)
341+
def double(x):
342+
return x * 2
343+
344+
sess = chs.Session()
345+
sess.query("CREATE TABLE t (x Int64) ENGINE = Memory")
346+
sess.query("INSERT INTO t VALUES (1), (2), (3)")
347+
result = sess.query("SELECT double(x) FROM t ORDER BY x", "CSV")
348+
print(result)
349+
# 2
350+
# 4
351+
# 6
352+
```

0 commit comments

Comments
 (0)