This project is a small tutorial-style Project Panama binding to SQLite. It uses the Java 25 Foreign Function and Memory API directly; there is no JDBC driver, no JNI wrapper, and no generated binding code.
The implemented SQLite C calls are:
| Area | Calls |
|---|---|
| Lifecycle | sqlite3_open_v2, sqlite3_close_v2 |
| Execution | sqlite3_prepare_v2, sqlite3_step, sqlite3_reset, sqlite3_finalize, sqlite3_exec |
| Binding | sqlite3_bind_int, sqlite3_bind_int64, sqlite3_bind_double, sqlite3_bind_text, sqlite3_bind_blob, sqlite3_bind_null, sqlite3_bind_parameter_count, sqlite3_clear_bindings |
| Reading | sqlite3_column_int, sqlite3_column_int64, sqlite3_column_double, sqlite3_column_text, sqlite3_column_blob, sqlite3_column_bytes, sqlite3_column_count, sqlite3_column_name, sqlite3_column_type |
| Utility | sqlite3_errmsg, sqlite3_errcode, sqlite3_extended_errcode, sqlite3_errstr, sqlite3_changes, sqlite3_last_insert_rowid, sqlite3_busy_timeout, sqlite3_interrupt |
- Java 25. This was tested with Oracle GraalVM 25.0.2.
- Maven 3.9 or newer.
- A native SQLite runtime library.
- A C compiler and SQLite development headers if you want the optional batch accelerator.
native-imagefrom GraalVM 25 if you want the native executable.
On this macOS machine the SQLite command line tool is provided by the system, but the dynamic library has to be opened by path:
/usr/lib/libsqlite3.dylibIf your SQLite library is somewhere else, set one of these before running:
export SQLITE_LIBRARY=/path/to/libsqlite3.dylib
java -Dsqlite.library=/path/to/libsqlite3.dylib ...Run the tests:
mvn testRun the tutorial app on the JVM:
mvn -DskipTests package
java --enable-native-access=ALL-UNNAMED -cp target/classes dev.panama.sqlite.TutorialAppExpected output:
== Wrapper API demo ==
wrapper bind parameters=6
changes=1 last_insert_rowid=3
columns=0:id, 1:note_text, 2:note_count, 3:precise_count, 4:score, 5:payload, 6:optional_value
row id=1 text=hello from Java 25 Project Panama #1 text_type=TEXT int=7 int64=9000000001 double=13.50 blob=[1, 2, 3, 1] blob_type=BLOB null_type=NULL
row id=2 text=hello from Java 25 Project Panama #2 text_type=TEXT int=14 int64=9000000002 double=14.50 blob=[1, 2, 3, 2] blob_type=BLOB null_type=NULL
row id=3 text=hello from Java 25 Project Panama #3 text_type=TEXT int=21 int64=9000000003 double=15.50 blob=[1, 2, 3, 3] blob_type=BLOB null_type=NULL
reset first_row_id=1
error code=1 extended=1 errstr=SQL logic error message=near "from": syntax error
== Raw SqliteNative FFI demo ==
raw bind parameters=6
sqlite3_changes=1 sqlite3_last_insert_rowid=2
raw columns=0:note_count, 1:precise_count, 2:score, 3:note_text, 4:payload, 5:optional_value
raw row int=11 int64=8000000000 double=3.75 text=raw Panama text 0 text_type=TEXT blob=[42, 43, 44, 45] blob_type=BLOB null=null null_type=NULL
raw row int=12 int64=8000000001 double=4.75 text=raw Panama text 1 text_type=TEXT blob=[43, 43, 44, 45] blob_type=BLOB null=null null_type=NULL
raw error code=1 extended=1 errstr=SQL logic error message=near "from": syntax error
sqlite3_close_v2: OK
Build and run the GraalVM native image:
native-image --no-fallback -cp target/classes -o target/ffi-sqlite dev.panama.sqlite.TutorialApp
./target/ffi-sqliteThere is also a small Makefile:
make test
make run
make native-run
make native-lib
make benchmark
make native-benchmarkThe benchmark compares this direct FFM binding with the JNI-based sqlite-jdbc driver. It keeps the scalar FFI path visible and adds a reduced-crossing FFI path for batched work:
- each run creates a fresh in-memory SQLite database;
- both paths create the same table;
- inserts use one prepared statement inside one transaction;
- reads scan the inserted rows and compute a checksum;
- the
textworkload binds text, integer, and double values; - the
integerworkload removes text conversion to isolate per-call overhead; - the scalar FFI path calls the SQLite C API one function at a time;
- the batch FFI path uses
executeBatch(...),executeBatchInt64(...), and chunked int64 reads to reduce Java/native crossings; - the text checksum routine is benchmark-only because it measures read-side crossing overhead but is not a general application API;
- the harness does warmup runs, alternates FFM/JNI order, then reports the median measured time.
Run the default batch sizes:
make benchmarkOr pass explicit row counts:
make native-lib
mvn -DskipTests test-compile \
org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath \
-Dmdep.outputFile=target/benchmark-classpath.txt
java --enable-native-access=ALL-UNNAMED \
-cp "target/classes:target/test-classes:$(cat target/benchmark-classpath.txt)" \
dev.panama.sqlite.SqliteBenchmark 1000 10000 50000Treat the numbers as a practical comparison for this tutorial binding, not a formal Java microbenchmark. For formal JVM benchmarking, use JMH and keep the same database rules: prepared statement reuse, explicit transactions, warmups, repeated measured runs, clear control over whether the database is in-memory or backed by disk, and separate measurements for scalar calls versus batch APIs.
Run the same benchmark from a GraalVM native executable:
make native-benchmarkFor a faster local check, pass smaller row counts:
make native-benchmark BENCH_ARGS="100 1000"The native benchmark includes sqlite-jdbc and the optional @CFunction backend, so the native-image build needs the SQLite JDBC initialization flag and the C library linker settings:
native-image --no-fallback \
-H:CLibraryPath=target/native \
-H:NativeLinkerOption=-lsqlite3 \
--initialize-at-build-time=org.sqlite.util.ProcessRunner \
-cp "target/classes:target/test-classes:$(cat target/benchmark-classpath.txt)" \
-o target/sqlite-benchmark \
dev.panama.sqlite.SqliteBenchmarkOn the local GraalVM 25.0.2 run, the JVM benchmark favored the reduced-crossing FFI path:
Workload, Rows, FFI scalar median ms, FFI batch median ms, JNI median ms, FFI scalar rows/s, FFI batch rows/s, JNI rows/s, FFI scalar/JNI time, FFI batch/JNI time
text, 100, 1.152, 0.572, 0.905, 86815, 174685, 110558, 1.273, 0.633
text, 1000, 1.141, 1.065, 1.530, 876393, 938784, 653613, 0.746, 0.696
text, 5000, 2.522, 2.283, 5.391, 1982357, 2189861, 927479, 0.468, 0.424
text, 20000, 7.620, 7.382, 15.299, 2624557, 2709155, 1307300, 0.498, 0.483
integer, 100, 0.095, 0.100, 0.143, 1054485, 995441, 701754, 0.665, 0.705
integer, 1000, 0.359, 0.314, 0.500, 2785189, 3186835, 1999000, 0.718, 0.627
integer, 5000, 1.537, 1.224, 2.040, 3253268, 4085802, 2450680, 0.753, 0.600
integer, 20000, 5.159, 4.470, 6.672, 3876375, 4473940, 2997508, 0.773, 0.670
The native-image benchmark shows why batching matters. The batch backend line confirms that native-image selected the GraalVM-specific @CFunction backend. The scalar FFI path remains much slower than JNI, but the reduced-crossing integer path is faster than JNI and the mixed text path is in the same range:
FFI batch backend: native-image CFunction
FFI benchmark backend: native-image CFunction
Workload, Rows, FFI scalar median ms, FFI batch median ms, JNI median ms, FFI scalar rows/s, FFI batch rows/s, JNI rows/s, FFI scalar/JNI time, FFI batch/JNI time
text, 100, 4.009, 0.299, 0.140, 24942, 334822, 716846, 28.740, 2.141
text, 1000, 32.420, 1.453, 0.789, 30845, 688370, 1267026, 41.077, 1.841
text, 5000, 154.528, 7.216, 3.293, 32357, 692917, 1518218, 46.921, 2.191
text, 20000, 608.904, 26.660, 12.142, 32846, 750191, 1647203, 50.149, 2.196
integer, 100, 2.021, 0.107, 0.084, 49472, 932036, 1184020, 23.933, 1.270
integer, 1000, 21.929, 0.385, 0.548, 45601, 2596843, 1823846, 39.995, 0.702
integer, 5000, 101.916, 1.202, 2.112, 49060, 4160741, 2367657, 48.260, 0.569
integer, 20000, 407.283, 4.481, 8.369, 49106, 4463705, 2389712, 48.664, 0.535
That result means the tutorial should not claim scalar native-image FFM is automatically faster. The competitive design is to keep the one-call-per-SQLite-function API for clarity and add explicit batch APIs for hot loops.
src/main/java/dev/panama/sqlite/SqliteNative.java
src/main/java/dev/panama/sqlite/SqliteAcceleratedNative.java
src/main/java/dev/panama/sqlite/SqliteNativeImageBackend.java
src/main/c/ffi_sqlite_accel.c
The hand-crafted FFI layer. It loads SQLite symbols, defines C layouts, creates MethodHandle downcalls, and exposes one public Java method per SQLite C function. The accelerator adds a small C library for batch execution and chunked int64 reads. The native-image backend is an optional GraalVM-specific path for the same accelerator calls.
src/main/java/dev/panama/sqlite/SqliteDatabase.java
src/main/java/dev/panama/sqlite/SqliteStatement.java
A small AutoCloseable wrapper over database and statement handles. These classes make the example pleasant to use while keeping the native layer easy to inspect.
src/main/java/dev/panama/sqlite/TutorialApp.java
src/test/java/dev/panama/sqlite/SqliteDatabaseTest.java
src/test/java/dev/panama/sqlite/SqliteBenchmark.java
src/test/c/ffi_sqlite_bench.c
Executable tutorial, tests, benchmark, and benchmark-only native measurement code.
src/main/resources/META-INF/native-image/dev.panama/ffi-sqlite/
Native Image metadata. This is what lets GraalVM see the FFM downcall shapes ahead of time.
The binding starts with a SymbolLookup:
private static SymbolLookup loadSqlite() {
return SymbolLookup.libraryLookup("/usr/lib/libsqlite3.dylib", Arena.global());
}The project version tries several common names and paths:
-Dsqlite.library=...SQLITE_LIBRARY=...sqlite3libsqlite3.dylib/usr/lib/libsqlite3.dylib- Homebrew paths
- Linux
.sonames - Windows
sqlite3.dll
That lookup object is later used to resolve symbols such as sqlite3_open_v2.
The FFM linker knows platform-specific C ABI layouts:
private static final Linker LINKER = Linker.nativeLinker();
private static final Map<String, MemoryLayout> C = LINKER.canonicalLayouts();
static final MemoryLayout C_INT = C.get("int");
static final MemoryLayout C_LONG_LONG = C.get("long long");
static final MemoryLayout C_DOUBLE = C.get("double");
static final AddressLayout C_POINTER = (AddressLayout) C.get("void*");This avoids guessing whether a native type is 32-bit or 64-bit on the current platform.
Each SQLite function gets a MethodHandle:
private static final MethodHandle SQLITE3_OPEN_V2 = downcall(
"sqlite3_open_v2",
FunctionDescriptor.of(C_INT, C_POINTER, C_POINTER, C_INT, C_POINTER));That descriptor means:
int sqlite3_open_v2(
const char *filename,
sqlite3 **ppDb,
int flags,
const char *zVfs
);The lookup method is intentionally small:
private static MethodHandle downcall(String symbol, FunctionDescriptor descriptor) {
MemorySegment address = SQLITE.find(symbol)
.orElseThrow(() -> new IllegalStateException("SQLite symbol not found: " + symbol));
return LINKER.downcallHandle(address, descriptor);
}sqlite3_open_v2 returns the database handle through sqlite3 **ppDb. In Java FFM that means allocating one native pointer-sized slot, then reading the pointer back:
try (Arena arena = Arena.ofConfined()) {
MemorySegment cFilename = arena.allocateFrom(":memory:");
MemorySegment databasePointer = arena.allocate(SqliteNative.C_POINTER);
databasePointer.set(SqliteNative.C_POINTER, 0, MemorySegment.NULL);
int code = SqliteNative.openV2(
cFilename,
databasePointer,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
MemorySegment.NULL);
MemorySegment database = databasePointer.get(SqliteNative.C_POINTER, 0);
}The filename memory only has to live through the call. The returned database pointer is owned by SQLite and stays valid until sqlite3_close_v2.
Preparing a statement is the same pointer-to-pointer pattern:
MemorySegment cSql = arena.allocateFrom(sql);
MemorySegment statementPointer = arena.allocate(SqliteNative.C_POINTER);
int code = SqliteNative.prepareV2(
database.handle(),
cSql,
-1,
statementPointer,
MemorySegment.NULL);
MemorySegment statement = statementPointer.get(SqliteNative.C_POINTER, 0);Passing -1 lets SQLite read the SQL string until its terminating zero byte.
Integer and double values are direct:
sqlite3_bind_int(statement, index, value)
sqlite3_bind_int64(statement, index, value)
sqlite3_bind_double(statement, index, value)Text and blob values need native memory. This project stores bound text/blob memory in an arena owned by SqliteStatement:
MemorySegment cText = boundValues.allocate(Math.max(bytes.length, 1));
SqliteNative.bindText(statement, index, cText, bytes.length, MemorySegment.NULL);The final MemorySegment.NULL is SQLite's SQLITE_STATIC destructor mode. That means SQLite does not copy or free your buffer. The Java side must keep the memory valid until the statement is finalized or the value is rebound. SqliteStatement does that by closing the arena after sqlite3_finalize.
Null is explicit:
sqlite3_bind_null(statement, index)SQLite bind indexes start at 1, not 0.
sqlite3_step returns:
SQLITE_ROWwhile a row is available.SQLITE_DONEwhen execution is complete.- Another code on error.
Text and blob reads require two calls. First get the pointer, then ask SQLite for the byte count:
MemorySegment pointer = SqliteNative.columnText(statement, column);
int byteCount = SqliteNative.columnBytes(statement, column);The column APIs are zero-based, unlike bind indexes.
For text, copy byteCount bytes and decode UTF-8. For blobs, copy the bytes as-is. The pointer returned by SQLite is only valid until the statement is stepped again, finalized, or the column is converted by another SQLite call, so this wrapper copies immediately.
Real SQLite code normally prepares a statement once, then reuses it for a batch. After sqlite3_step returns SQLITE_DONE, call:
sqlite3_reset(statement)
sqlite3_clear_bindings(statement)sqlite3_reset moves the statement back to its initial state while keeping the compiled SQL. sqlite3_clear_bindings removes old parameter values so the next iteration cannot accidentally reuse stale text or blob pointers.
The wrapper exposes that conservative pattern as resetAndClearBindings():
try (SqliteStatement insert = database.prepare("""
insert into notes (text, score)
values (?, ?)
""")) {
for (int i = 0; i < 1000; i++) {
insert.bindText(1, "note-" + i)
.bindDouble(2, i * 0.5)
.execute();
insert.resetAndClearBindings();
}
}For tight loops where every parameter is rebound on every iteration, use reset() only. That avoids an extra sqlite3_clear_bindings call and keeps the benchmark closer to what mature SQLite wrappers do:
insert.bindText(1, "note")
.bindDouble(2, 1.5)
.execute();
insert.reset();At prepare time, the wrapper caches sqlite3_bind_parameter_count so Java code can fail with a clear IllegalArgumentException when a bind index is outside SQLite's 1..N parameter range without calling SQLite again on every bind.
Scalar calls are the clearest way to learn FFM, but each bind, step, reset, and column read crosses from Java to native code. GraalVM native-image currently makes that per-call cost visible. For hot loops, keep the prepared statement but move repeated bind/step/reset work behind one FFM call.
The wrapper keeps the scalar methods and adds explicit batch APIs:
try (SqliteStatement insert = database.prepare("""
insert into notes (text, amount, score)
values (?, ?, ?)
""")) {
int completed = insert.executeBatch(new Object[][]{
{"first", 10L, 1.5},
{"second", 20L, 2.5}
});
System.out.println(completed);
}executeBatch(...) accepts null, Integer, Short, Long, Float, Double, String, and byte[]. Internally, Java stages the row values in native memory and ffi_sqlite_execute_batch performs the repeated SQLite calls in C.
For integer-heavy writes, use the primitive row-major form to avoid boxing:
try (SqliteStatement insert = database.prepare("""
insert into counters (amount, doubled)
values (?, ?)
""")) {
insert.executeBatchInt64(new long[]{
10, 20,
11, 22,
12, 24
});
}For integer-heavy reads, readAllInt64Columns(...) steps in chunks and returns row-major Java long values:
try (SqliteStatement query = database.prepare("""
select id, amount, doubled
from counters
order by id
""")) {
long[] values = query.readAllInt64Columns(0, 1, 2);
}These batch APIs belong in the core wrapper because they are useful application APIs. The text checksum function used by the benchmark stays in src/test: it is a measurement routine, not a general row-reading API.
The accelerator is optional. If the native library is not available, the public batch APIs fall back to the scalar implementation so tests and examples still run.
Generic query tools cannot assume the selected columns in advance. These calls expose the result shape:
int count = sqlite3_column_count(statement);
String name = sqlite3_column_name(statement, column);
int type = sqlite3_column_type(statement, column);The wrapper keeps column indexes zero-based, matching SQLite:
try (SqliteStatement query = database.prepare("select id, text, payload from notes")) {
for (int column = 0; column < query.columnCount(); column++) {
System.out.println(column + ": " + query.columnName(column));
}
while (query.step()) {
for (int column = 0; column < query.columnCount(); column++) {
System.out.println(query.columnName(column) + " type=" + query.columnType(column));
}
}
}The wrapper caches sqlite3_column_count at prepare time because the result shape is fixed for a prepared statement. sqlite3_column_type is still read from SQLite for each row because it reports the dynamic SQLite storage class for the current value: integer, float, text, blob, or null.
Prepared statements remain the main teaching path because they show the lifecycle clearly. For schema setup, PRAGMA statements, and transactions, sqlite3_exec is more practical:
database.execute("""
pragma foreign_keys = on;
create table notes (
id integer primary key,
text text not null
);
""");The transaction convenience methods are plain SQL over sqlite3_exec:
database.beginTransaction();
try {
// batch inserts or updates
database.commit();
} catch (RuntimeException error) {
database.rollback();
throw error;
}Wrapping batches in a transaction is one of the most important SQLite performance rules. Without it, each write can become its own transaction.
sqlite3_busy_timeout tells SQLite how long to wait when a table or database is locked by another connection:
database.setBusyTimeoutMillis(1000);sqlite3_interrupt requests cancellation of a long-running operation on the same database handle:
database.interrupt();The wrapper also exposes structured errors:
try {
database.execute("select from notes");
} catch (SqliteException error) {
System.out.println(error.code());
System.out.println(error.extendedCode());
System.out.println(SqliteNative.errstr(error.code()));
}The immediate message still comes from sqlite3_errmsg, but storing the primary and extended result codes makes tests and calling code less dependent on localized message text.
Every prepared statement must be finalized:
sqlite3_finalize(statement)Every database must be closed:
sqlite3_close_v2(database)The public API uses AutoCloseable, so normal usage is:
try (SqliteDatabase database = SqliteDatabase.openInMemory();
SqliteStatement statement = database.prepare("select 1")) {
while (statement.step()) {
System.out.println(statement.columnInt(0));
}
}GraalVM Native Image must know the FFM downcall signatures during image build. This project declares them in:
src/main/resources/META-INF/native-image/dev.panama/ffi-sqlite/reachability-metadata.json
Example entry:
{
"returnType": "int",
"parameterTypes": [
"void*",
"void*",
"int",
"void*"
]
}That entry covers sqlite3_open_v2. The file contains entries for every distinct downcall shape used by the binding.
The native-image properties also enable native access and defer SQLite symbol lookup until runtime:
Args = --enable-native-access=ALL-UNNAMED \
--initialize-at-run-time=dev.panama.sqlite.SqliteNative,dev.panama.sqlite.SqliteAcceleratedNative
Runtime initialization is important because SQLite is a host library. The binary should look it up on the machine where the binary is executed, not during image build.
The batch accelerator is another runtime library. The Makefile builds it into target/native/, which is one of the paths checked by SqliteAcceleratedNative. You can also point to a specific library:
export SQLITE_ACCEL_LIBRARY=/path/to/libffi_sqlite_accel.dylib
java -Dsqlite.accel.library=/path/to/libffi_sqlite_accel.dylib ...The previous chapter keeps the project on standard Java FFM. GraalVM Native Image also has its own C interface in the Native Image SDK. This project uses it as an optional add-on for the batch accelerator:
@CLibrary(value = "ffi_sqlite_accel", requireStatic = true, dependsOn = "sqlite3")
final class SqliteNativeImageBackend {
@CFunction("ffi_sqlite_execute_int64_batch")
private static native int ffiSqliteExecuteInt64Batch(
VoidPointer statement,
int parameterCount,
VoidPointer values,
int rowCount,
CIntPointer completed);
}This is not Project Panama and it is not portable Java. It only works in GraalVM native-image builds, so the wrapper treats it as a backend implementation detail:
- JVM runs use the FFM accelerator.
- Native-image runs can use
@CFunction. - If the accelerator is not available, public batch APIs fall back to scalar calls.
The Makefile builds both library forms:
make native-liblibffi_sqlite_accel.dylibor.sois used by the portable FFM backend.libffi_sqlite_accel.ais linked into native-image for the@CFunctionbackend.libffi_sqlite_bench.ais linked into the native-image benchmark for the benchmark-only checksum routine.
The native-image build must know where the static library is and must link SQLite too:
native-image \
-H:CLibraryPath=target/native \
-H:NativeLinkerOption=-lsqlite3 \
...There is a tempting CFunction.Transition.NO_TRANSITION option, but this project does not use it. SQLite can block on locks and IO, and a no-transition call delays safepoints while native code runs. The default transition is the safer tutorial choice.
This backend is useful when you are willing to give up portability for the best native-image speed. Keep the public Java API the same, hide the GraalVM-specific code behind backend selection, and benchmark before assuming it helps.
The measured native-image limitation is per-call crossing cost. The following changes preserve the standard Java FFM tutorial path:
- prepare statements once and reuse them with
reset(); - wrap writes in explicit transactions;
- avoid
clearBindings()when every parameter is rebound; - cache metadata such as parameter count and column count;
- use primitive arrays for homogeneous numeric batches;
- batch repeated bind/step/reset loops behind one native call;
- chunk numeric result scans with
readAllInt64Columns(...); - keep workload-specific native routines in the benchmark, not in the public wrapper.
GraalVM and Java also expose lower-level knobs, but they are narrower:
Linker.Option.critical(false)can be tested for tiny non-blocking calls. It is not a safe default forsqlite3_step,sqlite3_prepare_v2,sqlite3_exec,sqlite3_finalize, or any call that might wait on locks, IO, allocation, cleanup, or callbacks.- Native Image Profile-Guided Optimization can improve generated Java code, but it does not remove the cost of crossing into C for every bind, step, reset, and column read.
- Compiling the small C accelerator with
-O3is already done by the Makefile. Platform-specific compiler flags such as-march=nativeare useful for local experiments, but they reduce binary portability.
The local GraalVM 25.0.2 installation shows separate implementation paths for FFM downcalls and Native Image SDK C calls. svm-foreign.jar contains the FFM downcall stub machinery, while svm.jar contains the @CFunction call path. The Truffle runtime also contains an NFI/libffi layer used by guest-language native interop. That is not a hidden replacement for Java FFM in this project; for this tutorial, the practical choices are still standard FFM, fewer crossings through a C batch routine, or a GraalVM-specific @CFunction backend.
try (SqliteDatabase database = SqliteDatabase.openInMemory()) {
database.execute("create table notes (id integer primary key, text text)");
try (SqliteStatement insert = database.prepare("insert into notes (text) values (?)")) {
insert.bindText(1, "hello").execute();
}
try (SqliteStatement query = database.prepare("select id, text from notes")) {
while (query.step()) {
System.out.println(query.columnInt64(0) + ": " + query.columnText(1));
}
}
}- This is a teaching binding, not a full SQLite driver.
- SQLite text is treated as UTF-8.
- Text/blob values are copied into native memory before binding.
- Text/blob column values are copied back into Java arrays/strings immediately.
- Scalar
sqlite3_bind_textandsqlite3_bind_blobcalls useSQLITE_STATICsemantics, with lifetime managed by the statement arena. - Batch text/blob execution uses
SQLITE_TRANSIENTinside the accelerator so SQLite owns its copy after each staged row is bound. - The
@CFunctionbackend is GraalVM-specific and requires native-image linking options. sqlite3_close_v2allows closing a database even when child objects still exist, but this wrapper still expects you to close statements promptly.