Python 함수 API

Python 함수 API (Python Function API)

Python 함수에서 DuckDB 사용자 정의 함수(UDF)를 만들어 SQL 쿼리에서 쓸 수 있어요. 일반 함수와 마찬가지로 이름, 반환 타입, 파라미터 타입이 필요해요. 워낙 강력해서 서드파티 라이브러리도 그대로 가져올 수 있답니다.

출처: 문서

본문

Python 함수에서 DuckDB 사용자 정의 함수(UDF)를 만들어 SQL 쿼리에서 사용할 수 있어요. 일반 함수와 마찬가지로 이름, 반환 타입, 파라미터 타입이 필요해요.

서드파티 라이브러리를 호출하는 Python 함수를 사용한 예시가 있어요.

import duckdb
from duckdb.sqltypes import VARCHAR
from faker import Faker

def generate_random_name():
    fake = Faker()
    return fake.name()

duckdb.create_function("random_name", generate_random_name, [], VARCHAR)
res = duckdb.sql("SELECT random_name()").fetchall()
print(res)
[('Gerald Ashley',)]

함수 만들기 (Creating Functions)

Python UDF를 등록하려면 DuckDB 연결에서 create_function 메서드를 사용해요. 문법은 다음과 같아요:

import duckdb
con = duckdb.connect()
con.create_function(name, function, parameters, return_type)

create_function 메서드는 다음 파라미터를 받아요:

  1. name 연결 카탈로그 내 UDF의 고유 이름을 나타내는 문자열.
  2. function UDF로 등록하려는 Python 함수.
  3. parameters 스칼라 함수는 하나 이상의 컬럼을 다룰 수 있어요. 이 파라미터는 입력으로 사용되는 컬럼 타입 목록을 받아요.
  4. return_type 스칼라 함수는 행마다 하나의 요소를 반환해요. 이 파라미터는 함수의 반환 타입을 지정해요.
  5. type (선택): DuckDB는 네이티브 Python 타입과 PyArrow 배열을 모두 지원해요. 기본적으로 type = 'native'로 가정되지만, type = 'arrow'를 지정해 PyArrow 배열을 쓸 수 있어요. 일반적으로 Arrow UDF를 쓰는 것이 native보다 훨씬 효율적이에요 — 배치(batches) 단위로 동작할 수 있기 때문이에요.
  6. null_handling (선택): 기본적으로 NULL 값은 자동으로 NULL-in NULL-out으로 처리돼요. 사용자는 null_handling = 'special'을 설정해 NULL 값에 대한 원하는 동작을 지정할 수 있어요.
  7. exception_handling (선택): 기본적으로 Python 함수에서 예외가 발생하면 Python에서 다시 던져져요(re-thrown). 이 동작을 비활성화하고 대신 NULL을 반환하려면 이 파라미터를 'return_null'로 설정하면 돼요.
  8. side_effects (선택): 기본적으로 함수는 같은 입력에 대해 같은 결과를 내야 한다고 기대해요. 함수의 결과가 어떤 종류의 무작위성에 영향받는다면 side_effectsTrue로 설정해야 해요.

UDF를 등록 해제하려면 UDF 이름으로 remove_function 메서드를 호출하면 돼요:

con.remove_function(name)

부분 함수 사용하기 (Using Partial Functions)

DuckDB UDF는 Python 부분 함수(partial functions)로도 만들 수 있어요.

아래 예시는 커스텀 로거가 실행 시각을 ISO 형식으로, 항상 UDF 생성 시 전달한 인자와 함수 호출에 제공된 입력 파라미터를 이어붙인(concatenation) 결과를 반환하는 방법을 보여줘요:

from datetime import datetime
import duckdb
import functools


def get_datetime_iso_format() -> str:
    return datetime.now().isoformat()


def logger_udf(func, arg1: str, arg2: int) -> str:
    return ' '.join([func(), arg1, str(arg2)])
    
    
with duckdb.connect() as con:
    con.sql("select * from range(10) tbl(id)").to_table("example_table")
    
    con.create_function(
        'custom_logger',
        functools.partial(logger_udf, get_datetime_iso_format, 'logging data')
    )
    rel = con.sql("SELECT custom_logger(id) from example_table;")
    rel.show()

    con.create_function(
        'another_custom_logger',
        functools.partial(logger_udf, get_datetime_iso_format, ':')
    )
    rel = con.sql("SELECT another_custom_logger(id) from example_table;")
    rel.show()
┌───────────────────────────────────────────┐
│             custom_logger(id)             │
│                  varchar                  │
├───────────────────────────────────────────┤
│ 2025-03-27T12:07:56.811251 logging data 0 │
│ 2025-03-27T12:07:56.811264 logging data 1 │
│ 2025-03-27T12:07:56.811266 logging data 2 │
│ 2025-03-27T12:07:56.811268 logging data 3 │
│ 2025-03-27T12:07:56.811269 logging data 4 │
│ 2025-03-27T12:07:56.811270 logging data 5 │
│ 2025-03-27T12:07:56.811271 logging data 6 │
│ 2025-03-27T12:07:56.811272 logging data 7 │
│ 2025-03-27T12:07:56.811274 logging data 8 │
│ 2025-03-27T12:07:56.811275 logging data 9 │
├───────────────────────────────────────────┤
│                  10 rows                  │
└───────────────────────────────────────────┘

┌────────────────────────────────┐
│   another_custom_logger(id)    │
│            varchar             │
├────────────────────────────────┤
│ 2025-03-27T12:07:56.812106 : 0 │
│ 2025-03-27T12:07:56.812116 : 1 │
│ 2025-03-27T12:07:56.812118 : 2 │
│ 2025-03-27T12:07:56.812119 : 3 │
│ 2025-03-27T12:07:56.812121 : 4 │
│ 2025-03-27T12:07:56.812122 : 5 │
│ 2025-03-27T12:07:56.812123 : 6 │
│ 2025-03-27T12:07:56.812124 : 7 │
│ 2025-03-27T12:07:56.812126 : 8 │
│ 2025-03-27T12:07:56.812127 : 9 │
├────────────────────────────────┤
│            10 rows             │
└────────────────────────────────┘

타입 어노테이션 (Type Annotation)

함수에 타입 어노테이션이 있으면 선택적 파라미터를 모두 생략할 수 있는 경우가 많아요. DuckDBPyType을 사용하면 많은 알려진 타입을 DuckDB의 타입 시스템으로 암묵적으로 변환할 수 있어요. 예를 들어:

import duckdb

def my_function(x: int) -> str:
    return x

duckdb.create_function("my_func", my_function)
print(duckdb.sql("SELECT my_func(42)"))
┌─────────────┐
│ my_func(42) │
│   varchar   │
├─────────────┤
│ 42          │
└─────────────┘

파라미터 목록 타입만 추론할 수 있다면 parametersNone을 넘겨야 해요.

NULL 처리 (NULL Handling)

기본적으로 함수가 NULL 값을 받으면 기본 NULL-handling의 일부로 즉시 NULL을 반환해요. 이게 원하지 않으면 이 파라미터를 명시적으로 "special"로 설정해야 해요.

import duckdb
from duckdb.sqltypes import BIGINT

def dont_intercept_null(x):
    return 5

duckdb.create_function("dont_intercept", dont_intercept_null, [BIGINT], BIGINT)
res = duckdb.sql("SELECT dont_intercept(NULL)").fetchall()
print(res)
[(None,)]

null_handling="special"을 쓰면:

import duckdb
from duckdb.sqltypes import BIGINT

def dont_intercept_null(x):
    return 5

duckdb.create_function("dont_intercept", dont_intercept_null, [BIGINT], BIGINT, null_handling="special")
res = duckdb.sql("SELECT dont_intercept(NULL)").fetchall()
print(res)
[(5,)]

함수가 NULL을 반환할 수 있다면 항상 null_handling="special"을 사용하세요.

import duckdb
from duckdb.sqltypes import VARCHAR


def return_str_or_none(x: str) -> str | None:
    if not x:
        return None
    
    return x

duckdb.create_function(
    "return_str_or_none",
    return_str_or_none,
    [VARCHAR],
    VARCHAR,
    null_handling="special"
)
res = duckdb.sql("SELECT return_str_or_none('')").fetchall()
print(res)
[(None,)]

예외 처리 (Exception Handling)

기본적으로 Python 함수에서 예외가 발생하면 그 예외를 전달(다시 던짐)해요. 이 동작을 비활성화하고 대신 NULL을 반환하려면 이 파라미터를 "return_null"로 설정해야 해요.

import duckdb
from duckdb.sqltypes import BIGINT

def will_throw():
    raise ValueError("ERROR")

duckdb.create_function("throws", will_throw, [], BIGINT)
try:
    res = duckdb.sql("SELECT throws()").fetchall()
except duckdb.InvalidInputException as e:
    print(e)

duckdb.create_function("doesnt_throw", will_throw, [], BIGINT, exception_handling="return_null")
res = duckdb.sql("SELECT doesnt_throw()").fetchall()
print(res)
Invalid Input Error:
Python exception occurred while executing the UDF: ValueError: ERROR

At:
  ...(5): will_throw
  ...(9): <module>
[(None,)]

부작용 (Side Effects)

기본적으로 DuckDB는 만든 함수가 순수(pure) 함수라고 가정해요 — 같은 입력에 같은 출력을 낸다는 뜻이에요. 함수가 이 규칙을 따르지 않는다면, 예를 들어 무작위성을 사용한다면, 이 함수에 side_effects가 있다고 표시해야 해요.

예를 들어 이 함수는 매 호출마다 새 값을 만들어요.

def count() -> int:
    old = count.counter;
    count.counter += 1
    return old

count.counter = 0

이 함수를 부작용이 있다고 표시하지 않고 만들면 결과는 다음과 같아요:

con = duckdb.connect()
con.create_function("my_counter", count, side_effects=False)
res = con.sql("SELECT my_counter() FROM range(10)").fetchall()
print(res)
[(0,), (0,), (0,), (0,), (0,), (0,), (0,), (0,), (0,), (0,)]

이는 분명히 원하는 결과가 아니에요. side_effects=True를 추가하면 결과는 기대한 대로 나와요:

con.remove_function("my_counter")
count.counter = 0
con.create_function("my_counter", count, side_effects=True)
res = con.sql("SELECT my_counter() FROM range(10)").fetchall()
print(res)
[(0,), (1,), (2,), (3,), (4,), (5,), (6,), (7,), (8,), (9,)]

Python 함수 타입 (Python Function Types)

현재 두 가지 함수 타입이 지원돼요: native(기본)와 arrow.

Arrow

함수가 arrow 배열을 받을 것으로 예상된다면 type 파라미터를 'arrow'로 설정해요.

이렇게 하면 시스템이 함수에 최대 STANDARD_VECTOR_SIZE 튜플의 arrow 배열을 제공하고, 함수에서도 같은 수의 튜플 배열을 반환하길 기대하게 돼요.

일반적으로 Arrow UDF를 쓰는 것이 native보다 훨씬 효율적이에요 — 배치 단위로 동작할 수 있기 때문이에요.

import duckdb
import pyarrow as pa
from duckdb.sqltypes import VARCHAR
from pyarrow import compute as pc


def mirror(strings: pa.Array, sep: pa.Array) -> pa.Array:
    assert isinstance(strings, pa.ChunkedArray)
    assert isinstance(sep, pa.ChunkedArray)
    return pc.binary_join_element_wise(strings, pc.ascii_reverse(strings), sep)


duckdb.create_function(
    "mirror",
    mirror,
    [VARCHAR, VARCHAR],
    return_type=VARCHAR,
    type="arrow",
)

duckdb.sql(
    "CREATE OR REPLACE TABLE strings AS SELECT 'hello' AS str UNION ALL SELECT 'world' AS str;"
)
print(duckdb.sql("SELECT mirror(str, '|') FROM strings;").fetchall())
[('hello|olleh',), ('world|dlrow',)]

Native

함수 타입이 native로 설정되면 함수는 한 번에 단일 튜플을 제공받고, 단일 값만 반환하길 기대해요. 이는 faker처럼 Arrow에서 동작하지 않는 Python 라이브러리와 상호작용하는 데 유용할 수 있어요:

import duckdb

from duckdb.sqltypes import DATE
from faker import Faker

def random_date():
    fake = Faker()
    return fake.date_between()

duckdb.create_function(
    "random_date",
    random_date,
    parameters=[],
    return_type=DATE,
    type="native",
)
res = duckdb.sql("SELECT random_date()").fetchall()
print(res)
[(datetime.date(2019, 5, 15),)]

더 알아보기 (Learn more)