Skip to content

Parameter Binding

The driver-side parameter-binding contract for pycubrid 1.x.

This document is the authoritative specification for how pycubrid converts Python values into SQL literals before sending them to CUBRID. Every claim in this document is backed by a citation to the implementation (pycubrid/_cursor_common.py) and to a unit test that pins the behavior. Any change to the rules below is a contract change governed by RELEASE_POLICY.md.


Table of Contents


Overview

pycubrid performs driver-side literal binding. When you call cursor.execute(sql, parameters), the driver:

  1. Tokenizes sql into segments split on unquoted, uncommented ? placeholders (split_on_placeholders, pycubrid/_cursor_common.py:46-119).
  2. Validates that the number of placeholders matches len(parameters) (pycubrid/_cursor_common.py:199-203).
  3. Converts each Python value to a SQL literal string via format_parameter (pycubrid/_cursor_common.py:141-181).
  4. Concatenates the segments and the rendered literals into a single SQL string (pycubrid/_cursor_common.py:204-208).
  5. Sends the fully-rendered SQL to CUBRID via PrepareAndExecutePacket (pycubrid/cursor.py:139-150, pycubrid/aio/cursor.py:114-117).

The binding implementation is shared verbatim between the synchronous (Cursor) and asynchronous (AsyncCursor) paths via CursorParamsMixin (pycubrid/_cursor_common.py:237-257). There is no behavioral divergence between sync and async binding; parity is enforced by tests/test_aio_cursor_parity.py and tests/test_split_placeholders.py.

This is not server-side prepared-statement binding. pycubrid does not send parameter values as a separate typed payload; the broker receives a complete SQL text per execute. The one exception on the ordinary cursors is the opt-in executemany(..., prepared=True) (#839), which sends typed host variables with PREPARE + EXECUTE_ARRAY. See Non-Guarantees and Explicit Limits. The separate, explicit sync-only pycubrid.compat.native prepared cursor does send typed values (INT, BIGINT, DOUBLE, NUMERIC, DATE, TIME, DATETIME, DATETIMETZ, VARBIT, string and SQL NULL) through FC2/FC3; see Prepared host-variable mapping. It is not a replacement for this ordinary 1.x contract; see its bounded #418 typed CAS design and API reference.


Placeholder Style

  • paramstyle = "qmark" (pycubrid/__init__.py:46), per PEP 249.
  • Placeholders are positional ?. There are no named, numeric, or pyformat placeholders.
  • The parameters argument to execute() must be a Sequence other than str/bytes/bytearray. Mappings are rejected with ProgrammingError (pycubrid/_cursor_common.py:194-197). The exact message text is informative; see Non-Guarantees and Explicit Limits.
  • A placeholder count mismatch raises ProgrammingError (pycubrid/_cursor_common.py:199-203). The exact message text is informative; see Non-Guarantees and Explicit Limits.

Type Mapping (Guarantees)

The following table is the authoritative type-to-literal mapping for 1.x. Every row cites the implementing function in pycubrid/_cursor_common.py and the test that pins the behavior.

The exception class raised for each error case (e.g. ProgrammingError) is part of the contract; the message text shown in the table is illustrative only and may be refined within 1.x. See Non-Guarantees and Explicit Limits.

Temporal subclasses in this table require the active C _datetime implementation; see the fallback restrictions below.

Python type SQL literal Implementation Pinned by
None NULL _cursor_common.format_parameter tests/test_param_security.py:95-97
bool 1 (True) / 0 (False) _cursor_common.format_parameter tests/test_param_security.py:98-102
int (and subclasses such as IntEnum/IntFlag) int.__repr__(value) (decimal digits of the value). See Numeric subclasses _cursor_common.format_parameter tests/test_param_security.py::TestFormatParameterTypes::test_int, ::test_numeric_subclass_renders_by_value
float (and subclasses) float.__repr__(value) (shortest round-trip form, identical to str() of a plain float, e.g. 1e+20), except -0.0, sent as -0.0e0, and a subnormal value (nonzero, below sys.float_info.min in magnitude), sent as the exact product (m * 2.409919865102884e-181) of two normal doubles (#988); nan/inf/-inf raise ProgrammingError (current message: "nan and inf are not supported by CUBRID") _cursor_common.format_parameter tests/test_param_security.py::TestFormatParameterTypes::test_float*, ::test_numeric_subclass_renders_by_value; tests/test_integration_float_literal.py
decimal.Decimal (and subclasses) Plain fixed-point digits (format(value, "f") on the value converted to a plain Decimal, unquoted, never E notation); sign, trailing zeros and scale kept; more than 38 literal digits raise DataError; NaN/Infinity raise ProgrammingError (current message: "nan and inf are not supported by CUBRID"); subclasses raise ProgrammingError when the C decimal module is unavailable. See Decimal parameters and Numeric subclasses _cursor_common.format_parameter tests/test_param_security.py::TestFormatParameterTypes::test_decimal*, ::TestPureDecimalFallback; tests/test_parity_integration.py::TestParityDecimalLiterals
str (and subclasses) Single-quoted literal; escaping per String Escaping, applied to a plain str copy of the value; NUL (U+0000) and Ctrl-Z (U+001A, \x1a) each raise ProgrammingError (current messages: "string parameter contains null byte", "string parameter contains Ctrl-Z (0x1A) byte"). See Text, binary and temporal subclasses _cursor_common.format_parameter / _cursor_common.escape_string tests/test_param_security.py:27-84, ::TestStrSubclassEscaping
bytes, bytearray (and subclasses) X'<hex>' (lowercase hex, bytes.hex(value) / bytearray.hex(value)) _cursor_common.format_parameter tests/test_param_security.py:104-106, 144-145, ::TestBinarySubclassRendering
datetime.datetime (naive, and subclasses) DATETIME'YYYY-MM-DD HH:MM:SS.mmm' — year zero-padded to 4 digits; microseconds truncated to milliseconds (microsecond // 1000) _cursor_common.format_parameter tests/test_param_security.py:124-127, ::TestTemporalSubclassRendering
datetime.datetime (tz-aware, and subclasses) DATETIMETZ'YYYY-MM-DD HH:MM:SS.mmm <tz>' where <tz> is tzinfo.key when present (e.g. Asia/Seoul; for fold=1 in a repeated DST hour followed by the abbreviation, or replaced by the offset, see below), otherwise a ±HH:MM numeric offset. A non-empty key must be a plain str matching [A-Za-z0-9_+/-]+, otherwise ProgrammingError (current message: "time zone key must be an IANA name matching [A-Za-z0-9_+/-]+") _cursor_common.format_parameter / _cursor_common._format_tz tests/test_param_security.py:147-169, ::TestTzinfoKey
datetime.date (and subclasses) DATE'YYYY-MM-DD' — year zero-padded to 4 digits _cursor_common.format_parameter tests/test_param_security.py:116-118, ::TestTemporalSubclassRendering
datetime.time (and subclasses) TIME'HH:MM:SS' — microseconds and tzinfo dropped _cursor_common.format_parameter tests/test_param_security.py:120-122, ::TestTemporalSubclassRendering
pycubrid.types.Set / Multiset / Sequence SET{e1, e2, ...} / MULTISET{...} / SEQUENCE{...} (each keyword renders as KEYWORD{} when empty, e.g. SET{}); each element rendered by the rows of this table with the connection's escape mode. Nested typed collections raise ProgrammingError (current message: "nested collection parameters are not supported"); plain containers as elements are rejected as below. See Typed collection parameters _cursor_common.py format_parameter typed-collection branch tests/test_typed_collections.py; tests/test_replay_parity.py::typed_collection_parameters, ::executemany_typed_collection_parameters, ::typed_collection_backslash_escape_processing; tests/test_integration_collections.py::TestTypedCollectionParameters
anything else, including objects that only claim a supported type through __class__ ProgrammingError (current message: "unsupported parameter type") _cursor_common.format_parameter tests/test_param_security.py:128-130, ::TestClassSpoofing; tests/test_cursor.py:233-235

Integers are converted directly to decimal strings without conversion to float, including values such as 10**1000 and -(10**1000) that exceed the float range. This formatting behavior does not guarantee that CUBRID can store the value; server numeric limits and Python's integer-to-string conversion limits still apply. Pinned by tests/test_param_security.py::TestFormatParameterTypes::test_large_int and ::test_bind_large_int.

Numeric subclasses

Subclasses of int, float and decimal.Decimal are rendered from their numeric value through the base-class methods (int.__repr__, float.__repr__, and format(Decimal(value), "f")), never through str(), repr() or format() on the object itself. Before #518 they were rendered with str(value), so a subclass that overrides __str__ changed the SQL text: on Python 3.10 enum.IntEnum members were sent as Color.RED and enum.IntFlag combinations as Perm.R|W, and a user subclass whose __str__ returned 1; DROP TABLE t injected that text verbatim. Color.RED is now sent as 1 and Perm.R | Perm.W as 6. A Decimal subclass is converted to a plain Decimal first, so overridden __format__, is_nan() or as_tuple() cannot alter the literal or bypass the NaN/Infinity and 38-digit checks. bool is checked before int and still renders as 1/0; it cannot be subclassed. The Decimal copy is only tamper-proof with CPython's C decimal module (_decimal): the pure-Python fallback (_pydecimal) copies _sign, _int and _exp through ordinary attribute reads, which a subclass can forge (#528). When the C module is unavailable, a Decimal subclass raises ProgrammingError; a plain Decimal is still accepted. Pinned by tests/test_param_security.py::TestFormatParameterTypes::test_numeric_subclass_renders_by_value, ::TestPureDecimalFallback and tests/test_parity_integration.py::TestParityNumericSubclassLiterals.

Text, binary and temporal subclasses

Subclasses of str, bytes, bytearray, datetime.datetime, datetime.date and datetime.time are rendered from their stored value, never through methods the subclass can override (#528):

  • str: the value is first copied to a plain str (str.__str__(value) called on the base class), and the NUL/Ctrl-Z checks and escaping run on that copy. An overridden replace(), __contains__(), __str__() or __format__() is never called. Before #528 a subclass whose replace() returned x'; DROP TABLE users; -- had that text sent unescaped.
  • bytes/bytearray: bytes.hex(value) / bytearray.hex(value) read the buffer directly; an overridden hex() or __bytes__() is never called.
  • Dates and times: the literal is built from the integer fields read through the base-class descriptors (datetime.date.year, datetime.datetime.hour, ...) with explicit zero padding, instead of strftime(). Overriding strftime(), isoformat() or the year/hour/... properties does not change the literal. The UTC offset comes from datetime.datetime.utcoffset() called on the base class, and its days/seconds/microseconds fields are read the same way.
  • Without the active C _datetime implementation, its pure-Python descriptors read attributes such as _year and _seconds, which subclasses can forge. date, datetime and time subclasses therefore raise ProgrammingError before any field read or timezone callback; pass exact base-class values. A timedelta subclass returned by tzinfo.utcoffset() is also rejected before key lookup or driver offset-field reads. Plain fallback temporal values and plain timedelta offsets remain accepted; C-backed subclasses are unchanged.
  • Years below 1000 are zero-padded to four digits (date(99, 1, 2) is sent as DATE'0099-01-02'). strftime("%Y") does not pad them on Linux, and CUBRID reads DATE'99-01-02' as 1999-01-02, so two-digit years were silently stored with the wrong year (#519); one- and three-digit years happened to round-trip and are unchanged in value.
  • tzinfo.key: a non-empty key is spliced into the DATETIMETZ literal only when it is a plain str matching [A-Za-z0-9_+/-]+ (every IANA name, such as Asia/Seoul, Etc/GMT+5 or America/Port-au-Prince, qualifies); anything else raises ProgrammingError. A missing, None or empty key still falls back to the numeric ±HH:MM offset.
  • Numeric offsets must be a whole number of minutes (#898). An offset with seconds or microseconds, such as +05:30:45, raises DataError in the literal and prepared paths instead of being truncated to +05:30, which stored an instant 45 seconds off. A tzinfo.key region is unaffected.
  • Repeated wall time (#819): CUBRID resolves a region-only literal in a repeated DST hour to the first occurrence, so a fold=0 value keeps the region-only literal (America/New_York), byte-identical to earlier releases. When the fold=0 offset of the value's wall time is larger than the fold=1 offset and value.fold is 1, the literal names the second occurrence: <key> <abbreviation> when both tzname() results are plain strs that differ and the second one matches [A-Za-z]{3,6} (America/New_York EST), otherwise the ±HH:MM offset of the second occurrence alone (-05:00), which loses the region but keeps the instant. A tzname() result is never spliced into SQL unless it matches that pattern. Other wall times keep the region alone. A skipped wall time (spring forward, fold=1 offset larger) is also sent with the region alone; CUBRID 10.2 and 11.4 reject it (Invalid utime) in every literal form.
  • Ordinary exceptions from timezone callbacks, key lookup or offset-field reads raise ProgrammingError (current message: "invalid tzinfo on datetime parameter") with the original exception as the cause, without formatting caller-controlled exception text. Invalid key values keep their specific error above, and an offset of None still renders a naive DATETIME without reading the key. BaseException interruptions propagate.

Dispatch uses type(value), not isinstance(), which also trusts an overridden __class__. An object that only claims to be one of the supported types through __class__ (for example a transparent proxy) raises ProgrammingError("unsupported parameter type"); unwrap it before binding. escape_string() likewise raises ProgrammingError for a non-str argument.

Plain values render exactly as before, except the year padding. Pinned by tests/test_param_security.py::TestPlainLiteralsUnchanged, ::TestStrSubclassEscaping, ::TestBinarySubclassRendering, ::TestTemporalSubclassRendering, ::TestTzinfoKey, ::TestHostileTzinfo, ::test_temporal_subclasses_in_real_pure_python_fallback, ::TestClassSpoofing, and live on CUBRID 10.2 and 11.4 (sync and async) by tests/test_parity_integration.py::TestParityLiteralHardening. The repeated DST hour behavior is pinned by tests/test_param_security.py::TestRepeatedWallTime and live on CUBRID 10.2 and 11.4 (sync and async, DATETIMETZ and TIMESTAMPTZ) by tests/test_integration_timezone.py::test_repeated_hour_binds_both_occurrences.

Decimal parameters

A finite decimal.Decimal is rendered in plain fixed-point notation, never with an exponent: Decimal("1E-7") becomes 0.0000001, Decimal("1E+5") becomes 100000. CUBRID parses a numeric literal written with E as DOUBLE, so the previous str(value) rendering (1E-7) silently turned such values into floating point and lost digits on insert (#517). The sign, trailing zeros and scale are kept as written: Decimal("1.10") is sent as 1.10 (CUBRID types it NUMERIC(3,2)) and Decimal("-0.00") as -0.00.

CUBRID accepts a plain numeric literal of at most 38 digits (the NUMERIC maximum precision) and rejects a longer one with error -494 "Invalid numeric". The digit count is that of the rendered literal: every digit of a nonzero integer part plus every fractional digit, including leading fractional zeros (0.0000001 has 7) and trailing zeros; a lone 0 integer part is not counted. A Decimal whose plain literal would need more than 38 digits raises DataError before anything is sent, instead of falling back to DOUBLE. That covers Decimal("1E-39"), 39 significant digits and any huge exponent such as Decimal("1E+999999999"), which is rejected without being expanded. Round or quantize such values before binding, or bind a float if DOUBLE semantics are intended.

A fractional literal is NUMERIC(p,s) on the server. An integral value without fractional digits (Decimal("42"), Decimal("1E+5")) renders as a plain integer literal, which CUBRID types by magnitude (INTEGER, BIGINT or NUMERIC(p,0)); the value stays exact. When the target column has a smaller scale than the literal, CUBRID rounds on assignment as for any literal (see Non-Guarantees). NaN and Infinity still raise ProgrammingError.

Pinned by tests/test_param_security.py::TestFormatParameterTypes::test_decimal_plain_notation, ::test_decimal_precision_38_accepted, ::test_decimal_precision_over_38_raises and ::test_bind_decimal_plain_notation, and live on CUBRID 10.2 and 11.4 (sync and async) by tests/test_parity_integration.py::TestParityDecimalLiterals.

Typed collection parameters

Wrap the elements in pycubrid.types.Set, Multiset or Sequence (also exported as pycubrid.Set, pycubrid.Multiset, pycubrid.Sequence) to bind a CUBRID collection through an ordinary sync or async cursor (#567):

from pycubrid.types import Multiset, Sequence, Set

cur.execute(
    "INSERT INTO t (tags, words, steps) VALUES (?, ?, ?)",
    (Set([1, 2, 3]), Multiset(["a", "a"]), Sequence([3, 1, 2])),
)
cur.execute("SELECT id FROM t WHERE tags SUBSETEQ ?", (Set([1, 2, 3, 4]),))
  • Each type takes one iterable and stores its elements as a tuple (.elements); the objects are immutable, compare equal only to the same type with equal elements, and cannot be subclassed. A single str, bytes or bytearray argument raises TypeError instead of being split into characters.
  • The literal keyword follows the type: SET{...}, MULTISET{...}, SEQUENCE{...} (CUBRID's LIST{...} is the same type). The server applies the collection semantics: SET drops duplicates, MULTISET keeps duplicates but not their order, SEQUENCE keeps both.
  • Every element goes through the same override-proof renderer as a scalar parameter, so the element types are exactly the scalar rows of the table above (None, bool, int, float, Decimal, str, bytes, bytearray, date, time, datetime), including their subclass hardening. The server converts the elements to the column's element type, as for a literal.
  • Nested collections are rejected (ProgrammingError): a typed collection inside another, or a plain list/tuple/set/frozenset/dict element.
  • A dict is rejected at construction time (TypeError) for all three classes: iterating it would silently use only its keys and drop the values. Sequence additionally rejects a set/frozenset (TypeError): their iteration order is not guaranteed, which would make Sequence's element order nondeterministic between runs. Set and Multiset accept a set/frozenset since their own server-side semantics do not depend on input order.
  • executemany() accepts typed collections in each parameter set, including through the DML batch path (EXECUTE_BATCH).
  • The instances are immutable and safe to copy.copy() (always returns the same object), copy.deepcopy() (the same object when every element is itself immutable; an independent copy, with independently copied elements, when an element such as bytearray is mutable) and pickle; re-invoking __init__ on an existing instance cannot mutate it either.
  • Fetching is unchanged: with decode_collections=True a SET column still decodes to frozenset and MULTISET/SEQUENCE to list (raw bytes otherwise). Decoded values are not wrapped back into these types; wrap them again (for example Set(row[0])) to bind them.

Explicitly unsupported as a bound value

  • datetime.timedelta — no branch; raises ProgrammingError("unsupported parameter type").
  • pycubrid.Lob — Lob instances are not converted to SQL literals. Insert raw bytes for BLOB/BIT-typed columns; for large object workflows use Connection.create_lob() plus the LOB write API (pycubrid/lob.py, pycubrid/connection.py:333-339).
  • Collections (list, tuple, set, frozenset, dict) as a single bound value — raise ProgrammingError with an actionable message (current text: cannot bind a collection (list/tuple/set/frozenset/dict) as a single parameter; pycubrid does not auto-expand IN (?, ?, ...) — expand the placeholders explicitly in the SQL, or wrap the elements in pycubrid.types.Set, Multiset or Sequence to bind a CUBRID collection). There is no automatic IN (?, ?, ?) expansion; expand placeholders explicitly in the SQL. To bind a CUBRID collection, wrap the elements in a typed collection parameter; the message also says so.
  • Arbitrary Python objects — raises ProgrammingError("unsupported parameter type").

Prepared host-variable mapping

This section does not change the literal contract above. It describes the typed values that the explicit pycubrid.compat.native cursor's bind_param() sends through FC3 (pycubrid/_prepared_binding.py _encode_prepared_scalar, re-exported from pycubrid.protocol). Since #840 it accepts the same Python types as the literal path, except typed collections (bind those with bind_set()) and empty bytes. Each value is sent as one type byte and its value bytes; nothing is rendered into SQL. The checks match the literal path: nan/inf raise ProgrammingError, a Decimal that needs more than 38 digits raises DataError, and forged or fallback-module subclasses are refused in the same way. Values are read through the base-class methods, so a subclass cannot change the bytes. Error messages never contain the value.

Python value Literal path Prepared type and value bytes
None NULL NULL (0), no value bytes
bool 1 / 0 INT (8), int32 1 / 0: the literal path's INTEGER value
int in signed 32-bit range decimal digits INT (8), int32 (unchanged since #439)
int in signed 64-bit range, outside 32-bit decimal digits BIGINT (21), int64
int outside 64-bit, at most 38 digits decimal digits (a NUMERIC literal) NUMERIC (7), decimal text and NUL; more digits raise DataError
float float.__repr__ DOUBLE (12), IEEE 754 binary64
decimal.Decimal fixed-point digits NUMERIC (7), the same fixed-point text with a lone leading 0 dropped (.5, -.05) and a NUL
str quoted literal CHAR (1), connection-charset bytes and NUL (unchanged)
bytes, bytearray X'<hex>' VARBIT (6), the bytes
datetime.datetime, naive DATETIME'…' DATETIME (22), seven int16 fields with milliseconds
datetime.datetime, aware DATETIMETZ'… <tz>' DATETIMETZ (31), the seven fields and the same <tz> text, without NUL
datetime.date DATE'…' DATE (13), year, month, day and four zero fields
datetime.time TIME'…' TIME (14), three zero fields, hour, minute, second and a zero field; tzinfo is ignored, as in the literal path

Every temporal value uses the 14-byte DATETIME layout, because the broker reads TIME from offset 6 and does not check the length. Microseconds are truncated to milliseconds for DATETIME and dropped for TIME, as in the literal path. The leading zero is dropped from NUMERIC text because the broker counts every character except the sign and the point as precision, so 0. followed by 38 digits would otherwise fail where the literal succeeds.

Host variables are coerced by the server as host variables, not parsed as SQL text, so some results differ from the literal path. Verified on CUBRID 10.2, 11.0, 11.2 and 11.4 (tests/test_integration_prepared_wide_types.py):

  • Empty bytes. b"" and bytearray() raise NotSupportedError: the broker reads a zero-length value as NULL, while the literal X'' is an empty bit string.
  • Floats. The literal 1.5 is a NUMERIC literal in CUBRID (only exponent forms such as 1e-07 are DOUBLE); the prepared value is always DOUBLE. Stored column values are the same, but the result type of SELECT ? and of expressions such as ? / 3 can differ. CUBRID rejects subnormal literals and reads the literal -0.0 as NUMERIC zero, so the literal path sends these two cases as DOUBLE expressions ((m * 2.409919865102884e-181) and -0.0e0) to store the same value as the prepared path (#988).
  • Server-side conversion errors. A value the target type cannot hold fails with error -494 (ProgrammingError, "Cannot coerce host var to type …") instead of the literal path's message. Examples: a BIGINT into an INTEGER column when it is out of range, and a TIME into a DATETIME target.
  • Typed collections. Set, Multiset and Sequence are rendered by the literal path but raise ProgrammingError in bind_param(); use connection.set() and bind_set(). executemany(..., prepared=True) sends them as collections of string elements (see below).
  • Integers past 38 digits raise DataError before any I/O. The literal path sends the digits and the server rejects them (-494, "Invalid numeric").

The pycubrid.compat.cubriddb wrapper cursors still accept only int, str and None scalars, so their bool, float, bytes and temporal arguments keep raising ProgrammingError. Their int arguments reach bind_param(), so a value past signed 32-bit now binds BIGINT or NUMERIC instead of raising DataError.


String Escaping

String escaping is performed by escape_string (pycubrid/_cursor_common.py). The behavior depends on the no_backslash_escapes connection flag (pycubrid/_connection_common.py). By default the flag is auto-negotiated from the live server at connect time (see Escape-mode negotiation); pass it explicitly to pycubrid.connect(..., no_backslash_escapes=True|False) to override detection. Automatic detection runs for each newly opened physical session, including explicit ping(reconnect=True) recovery, before parameterized SQL can use it.

In every mode:

  • The literal is wrapped in single quotes.
  • NUL (U+0000) in the input raises ProgrammingError (pycubrid/_cursor_common.py:130-131). This is unconditional and applies in both modes. The exact message text is informative; see Non-Guarantees and Explicit Limits.
  • Single quotes are doubled (' → '').
  • Unicode code points (including non-BMP characters that UTF-16 would encode as a surrogate pair) are passed through unchanged (tests/test_param_security.py::TestEscapeString::test_unicode_passthrough, ::test_unicode_non_bmp_passthrough).

Escape-mode negotiation

CUBRID's no_backslash_escapes system parameter defaults to yes — a backslash is an ordinary literal character, not an escape marker (CUBRID manual, literal.rst: "An escape using a backslash can be used if you set no_backslash_escapes in cubrid.conf as no. But this default value is yes."). If the driver blindly doubled backslashes against such a server, C:\temp\file would be stored as C:\\temp\\file — silent data corruption (issue #255).

To stay correct against whatever the server is actually configured for, when no_backslash_escapes is not passed to connect(), the driver probes the live server once at connect time with SELECT CHAR_LENGTH('\\') (the SQL literal '\\', two backslash characters):

  • result 2 → the server left both backslashes intact → literal mode, so the driver pins no_backslash_escapes=True (does NOT double backslashes).
  • result 1 → the server unescaped the pair → escape-processing mode, so the driver pins no_backslash_escapes=False.
  • any other value or a probe error → raises OperationalError. The driver refuses to guess the escape mode, because a wrong value silently corrupts string escaping (and can enable SQL injection). Pass no_backslash_escapes explicitly to skip detection when the probe cannot run.

Passing no_backslash_escapes=True or False explicitly skips the probe and retains that choice across reconnections. Without an explicit value, a newly opened physical session is probed before use; a healthy same-session ping() does not re-probe. A failed probe prevents direct connection setup; during ping(reconnect=True), it retires the replacement session and returns False. Neither path guesses a mode or replays interrupted SQL. In the sync and async paths, parameterized SQL bound before a session replacement is rejected before send if its session generation changed; the caller must deliberately retry the operation. Normal CAS_INFO=OUT_TRAN responses keep the session; only a failed pre-request CHECK_CAS reconnects (#485). Cursors run that check before rendering parameters; SQL rendered for a session that is replaced afterwards is rejected before send in sync and async alike. SQL strings passed directly to executemany_batch() are rendered by the caller and are not generation-fenced.

The 10.2 and 11.4 CUBRID manuals do not classify no_backslash_escapes as dynamically changeable. This driver behavior does not imply support for a per-session SET toggle or prove heterogeneous failover between differently configured servers. An explicit mode should be used only when every possible target is known to match it.

Literal mode (no_backslash_escapes=True)

This is what auto-negotiation selects against a stock CUBRID server (no_backslash_escapes=yes):

  1. Single quotes are doubled (' → '').
  2. Backslashes and control characters are left untouched (the server treats them as ordinary characters, so they round-trip byte-for-byte).
  3. NUL rejection still applies.
  4. The result is wrapped in single quotes.

Pinned by tests/test_aio_cursor_parity.py:99-105, tests/test_backslash_negotiation.py, and the live round-trip suite tests/test_integration.py::TestBackslashRoundTrip.

Escape-processing mode (no_backslash_escapes=False)

Selected when the server runs no_backslash_escapes=no, or when pinned explicitly. The driver doubles backslashes so the server unescapes them back:

  1. Backslashes are doubled (\ → \\).
  2. Single quotes are doubled (' → '').
  3. \r and \n are each prefixed with a backslash (\n → \\n, etc.).
  4. \x1a (Ctrl-Z) has no safe CUBRID literal escape and raises ProgrammingError in both escape modes (see String Escaping).
  5. The result is wrapped in single quotes.

Pinned by tests/test_param_security.py:27-55 and tests/test_aio_cursor_parity.py:87-96.


Placeholder Tokenizer

split_on_placeholders (pycubrid/_cursor_common.py:46-119) parses the SQL text and returns segments split only on ? characters that appear in executable SQL — never inside string literals, identifier quotes, or comments. Specifically the tokenizer recognizes and skips:

  • Single-quoted string literals ('...'), including doubled-quote escapes ('').
  • Double-quoted identifiers ("..."), including doubled-quote escapes ("").
  • Line comments (-- ... <EOL>).
  • Block comments (/* ... */).

A ? inside any of the above is part of the SQL text and is not treated as a placeholder. This is pinned by tests/test_split_placeholders.py in its entirety.

The CUBRID broker counts markers itself before running execute()'s statement, and it skips only comments and single-quoted strings: a ? inside a quoted identifier ("a?b", `a?b`, [a?b]) counts as a marker there (get_num_markers in src/broker/cas_execute.c). Such a statement fails with ProgrammingError (errno=-1007, error_source="cas"), whose message names this cause, even though the driver rendered it correctly (#990). Rename the identifier or alias. Pinned by tests/test_error_source.py and, on a live server, tests/test_integration_quoted_identifier_marker.py. The prepared paths see the same inflated count, because PREPARE reports the broker's marker count: executemany('INSERT INTO t (v) SELECT ? AS "q?" FROM db_root', [(5,)], prepared=True) raises the driver's ProgrammingError "wrong number of parameters", and [(5, 6)] runs while the broker silently ignores the extra value (verified on CUBRID 11.4, #992).

The replacement step concatenates segments and rendered literals (pycubrid/_cursor_common.py:204-208); it never performs a naïve str.replace("?", ...), so a literal ? in the rendered value of one parameter cannot inadvertently consume the next placeholder.

A rendered value never joins the SQL text before it into a comment (#893). When a literal that starts with - (a negative int, float or Decimal, including -0.0 and Decimal("-0")) directly follows a - in the SQL text, one space is inserted between them, so x-? with -5 is sent as x- -5 (x - (-5)) instead of x--5, which CUBRID reads as a line comment that drops the rest of the statement. Nothing else changes: a negative value after any other character (= -5, IN (-1, -2), LIMIT -1, 1+-2), a non-negative value after - (1-2) and collection elements (SET{-1, -2}, whose elements follow { or ,) render exactly as before. No other rendered literal starts with - or /, or ends with -, / or *, so no value can open --, // or /* with the surrounding SQL. A placeholder written directly against other SQL text (1?, ?1, x?) still pastes the value next to it; write a space or an operator there. Pinned by tests/test_negative_literal_comment.py and, on a live server, tests/test_integration_negative_literal.py.


executemany

For DML verbs (INSERT, UPDATE, DELETE, MERGE), executemany:

  1. Renders every parameter row with CursorParamsMixin._bind_parameter_sets() (pycubrid/_cursor_common.py). On the first row it resolves the escape mode, checks the row and splits the SQL on its placeholders, once per call (#837). Every row is then checked and rendered with that split. Each rendered string is byte-identical to what _bind_parameters(sql, params) returns for that row, and the first bad row raises the same error.
  2. Sends the list of rendered SQL strings in a single BatchExecutePacket through the private _executemany_batch() of Cursor (pycubrid/cursor.py) and AsyncCursor (pycubrid/aio/cursor.py).

For non-DML statements, executemany falls back to a per-row execute loop (Cursor._executemany_loop(), and the loop inside AsyncCursor.executemany()).

Each row is bound independently with the same type-mapping rules above.

executemany(..., prepared=True)

With prepared=True (#839) no SQL is rendered. For INSERT, UPDATE, DELETE and MERGE the cursor sends one PREPARE and one EXECUTE_ARRAY request (function code 21) whose arguments are one type byte and value per marker per row, in row order. Values follow the prepared host-variable mapping, with the same checks and the same differences from literals (host-variable coercion, float as DOUBLE, empty bytes rejected). In addition:

  • Set, Multiset and Sequence are sent as collections of STRING elements: str elements as they are, int elements (not bool) as decimal text and None as a NULL element; the server converts them to the column's element type. Other element types raise ProgrammingError. A Multiset is sent with the SEQUENCE kind, because the 10.2-11.4 brokers reject the MULTISET bind kind (-454) and a SET kind would drop duplicates.
  • A pycubrid.Lob of the same connection is bound by its handle, only with autocommit off.
  • Every row is encoded before anything is sent; a rejected value names its position (rows[i][j]) and never its contents. Each value carries its own type, so a column may hold an int in one row and a Decimal or None in the next.
  • seq_of_parameters may be any iterable. A generator is consumed once, and every encoded row is retained before network I/O, so memory use scales with the whole request rather than a streamed chunk. Use finite, bounded batches.
  • Other statements raise NotSupportedError; there is no fallback to the literal batch.

Non-Guarantees and Explicit Limits

The following behaviors are explicitly outside the contract and may change without a major version bump. They are listed so that callers do not depend on them implicitly.

  • Ordinary sync/async cursors do not use server-side prepared binding. pycubrid.Cursor and pycubrid.aio.AsyncCursor render parameters into SQL text on the client, so their execute() sends a complete SQL string without a typed value payload or statement-handle cache. Only the opt-in executemany(..., prepared=True) (#839) sends typed host variables; it prepares the statement once per call and caches no handle across calls. The separate, opt-in pycubrid.compat.native sync cursor has a narrow typed scalar contract; its performance and plan-cache effects are not claimed here.
  • Identifiers are not escaped. Code paths that interpolate identifiers (notably Cursor.callproc, pycubrid/cursor.py:328-336) embed the identifier into the SQL text without quoting. Application code must validate any identifier it accepts from untrusted input. Parameter binding (?) applies only to values, never to identifiers.
  • Type-object identity at the binding layer. PEP 249 type objects (STRING, BINARY, etc.) describe cursor.description; they are not consulted during parameter binding.
  • Server-side type coercion is not normalized. The driver renders a SQL literal; CUBRID then coerces the literal to the target column type per its own rules. The driver does not adjust precision, scale, or charset to match the destination column.
  • No driver-enforced length limits. Strings, byte buffers, and rendered SQL are bounded only by Python memory and CUBRID server limits.
  • No automatic IN-clause expansion. A list/tuple/set passed as a single bound value raises ProgrammingError. Expand placeholders in the SQL text yourself (e.g., f"... WHERE id IN ({','.join('?' * len(ids))})" with params=tuple(ids)).
  • Exception messages. The exact text of ProgrammingError messages ("unsupported parameter type", "wrong number of parameters", "string parameter contains null byte", "nan and inf are not supported by CUBRID", "parameters must be a sequence") is not part of the contract. The exception class is; the message text may be refined.
  • Behavioral subtleties not listed above. Anything not enumerated in Type Mapping or String Escaping is not a guarantee. The public-API surface gate (scripts/check_public_api.py) does not detect behavioral drift; it only detects structural surface changes. See RELEASE_POLICY.md §"What the gate does not detect".

Compatibility Policy (1.x)

Within the 1.x line, the following changes are governed by RELEASE_POLICY.md:

Allowed in a minor (1.y → 1.y+1) release

  • Adding support for a new Python type (e.g., uuid.UUID, datetime.timedelta) — additive only, with a new row added to the Type Mapping table.
  • Improving an exception message while keeping the exception class unchanged.
  • Internal refactoring of escape_string, format_parameter, or split_on_placeholders that does not change the produced SQL literal or the raised exception class for any input already covered above.
  • Adding new connection flags that modify binding behavior, provided the default value preserves the rules in this document.

Requires a major (1.y → 2.0) release

  • Removing or renaming any row in the Type Mapping table.
  • Changing the SQL literal produced for any input already in the table (for example: switching bytes from X'<hex>' to another representation, changing the default datetime precision, or changing the tz suffix format).
  • Tightening or loosening NUL / NaN / Inf rejection.
  • Changing the default value of no_backslash_escapes.
  • Changing the paramstyle from "qmark".
  • Changing the rejection of Mapping parameters (i.e., reintroducing or removing named-parameter support).

Deprecation flow

Behaviors slated for removal in a future major release are first marked deprecated in CHANGELOG.md > ### Deprecated, with a documented replacement and a migration window of at least one minor release before the major-version removal.


Pinned Tests

The following test modules pin the behavior documented above. CI failures in any of these are contract regressions:

  • tests/test_param_security.py — per-type formatting, NUL rejection, NaN/Inf rejection, datetime tz rendering, Decimal handling, bytes hex rendering.
  • tests/test_split_placeholders.py — placeholder tokenizer behavior across quoted strings, quoted identifiers, line comments, block comments, and integration with _bind_parameters.
  • tests/test_cursor.py (lines 183-235) — end-to-end binding of a multi-type parameter sequence, placeholder-count validation, and Mapping/str parameter rejection.
  • tests/test_aio_cursor_parity.py (lines 76-105) — sync/async parity for NUL rejection, default escaping, and no_backslash_escapes mode.
  • tests/test_typed_collections.py — typed Set/Multiset/Sequence rendering, element hardening and nested-collection rejection; sync/async parity in tests/test_replay_parity.py::typed_collection_parameters.
  • tests/test_execute_array.py — executemany(..., prepared=True): the EXECUTE_ARRAY request and reply bytes, per-row value encoding and position messages, the statement and LOB rules, and sync/async handle ownership.
  • tests/test_integration_execute_array.py — live prepared batches on CUBRID 10.2-11.4: partial failure, autocommit, rowcount, mixed types, NULLs, collections and LOB values against the literal batch.

Together these tests provide the executable specification of this contract.


References