|
| 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