SQLite is written entirely in C, which means the C API is the native, original interface to the database engine. Every other language binding you have ever used, whether it is Python’s sqlite3, Node’s better-sqlite3, or Java’s JDBC driver, is ultimately just a wrapper around this same C library. If you want to truly understand how SQLite works under the hood, or if you are building an embedded system, a game engine, or a performance-critical application in C or C++, learning the native API directly is incredibly useful. In this article, I am going to walk through the core pieces of the SQLite C/C++ interface, show you working code examples, and share the practices I rely on when I write SQLite-backed C or C++ applications.
Why Use the C API Directly?
Most developers never touch the raw C API because their language of choice already provides a comfortable wrapper. So why bother learning it?
I find the C API valuable when I am working on embedded devices with limited resources, building a C or C++ application where adding a heavier ORM or wrapper library is not worth the overhead, writing my own custom binding or extension for another language, or trying to debug a tricky performance issue and I need to understand exactly what is happening at the lowest level. Even if you never write raw C API calls in your day-to-day work, understanding this layer makes you a much stronger SQLite user overall, because it demystifies what your favorite language wrapper is actually doing behind the scenes.
Setting Up
To use the SQLite C API, you need the sqlite3.h header file and the SQLite library itself. On most Linux systems, you can install the development package:
sudo apt-get install libsqlite3-dev
Then, when compiling your program, you link against the library:
gcc my_program.c -o my_program -lsqlite3
Alternatively, SQLite is distributed as an “amalgamation,” a single sqlite3.c file containing the entire engine, which you can compile directly into your project without any external dependency. This is actually my preferred approach for embedded and cross-platform projects, because it avoids version mismatches with whatever SQLite library happens to be installed on the target system.
The Core Workflow
Almost every interaction with the C API follows the same basic pattern:
- Open a database connection with
sqlite3_open() - Prepare a SQL statement with
sqlite3_prepare_v2() - Bind any parameters with the
sqlite3_bind_*()family of functions - Step through the results with
sqlite3_step() - Read column values with the
sqlite3_column_*()family of functions - Finalize the statement with
sqlite3_finalize() - Close the connection with
sqlite3_close()
Let me walk through each of these in detail.
Opening a Database Connection
#include <stdio.h>
#include <sqlite3.h>
int main(void) {
sqlite3 *db;
int rc = sqlite3_open("my_database.db", &db);
if (rc != SQLITE_OK) {
fprintf(stderr, "Cannot open database: %s\n", sqlite3_errmsg(db));
sqlite3_close(db);
return 1;
}
printf("Database opened successfully.\n");
sqlite3_close(db);
return 0;
}
The sqlite3_open() function takes the filename and a pointer to a sqlite3* handle, which it populates. Every subsequent call needs this handle. Notice that even on failure, you still need to call sqlite3_close(), because SQLite may have partially allocated the handle. If you want more control over how the file is opened, such as opening it read-only or in-memory, sqlite3_open_v2() gives you additional flags to work with.
Creating a Table
To execute a simple statement that does not need parameter binding or row-by-row result processing, sqlite3_exec() is the most convenient function:
const char *sql =
"CREATE TABLE IF NOT EXISTS employees ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"name TEXT NOT NULL, "
"department TEXT, "
"salary REAL);";
char *err_msg = NULL;
rc = sqlite3_exec(db, sql, NULL, NULL, &err_msg);
if (rc != SQLITE_OK) {
fprintf(stderr, "SQL error: %s\n", err_msg);
sqlite3_free(err_msg);
}
sqlite3_exec() takes the database handle, the SQL string, an optional callback function for processing rows, an optional pointer passed to that callback, and a pointer to capture any error message. For statements like CREATE TABLE that do not return rows, this is the simplest way to run them.
Inserting Data With Prepared Statements
For anything involving parameters, especially when inserting user-supplied data, you should always use prepared statements rather than building SQL strings manually. This is critical for both performance and security.
sqlite3_stmt *stmt;
const char *insert_sql = "INSERT INTO employees (name, department, salary) VALUES (?, ?, ?);";
rc = sqlite3_prepare_v2(db, insert_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
fprintf(stderr, "Failed to prepare statement: %s\n", sqlite3_errmsg(db));
return 1;
}
sqlite3_bind_text(stmt, 1, "Amina Raza", -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, "Engineering", -1, SQLITE_STATIC);
sqlite3_bind_double(stmt, 3, 85000.0);
rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
fprintf(stderr, "Execution failed: %s\n", sqlite3_errmsg(db));
}
sqlite3_finalize(stmt);
A few things to note here. The question marks in the SQL string are placeholders, numbered starting at 1. sqlite3_bind_text(), sqlite3_bind_int(), sqlite3_bind_double(), and sqlite3_bind_blob() are used depending on the data type you are binding. The -1 passed as the length tells SQLite to compute the string length automatically using strlen(). The SQLITE_STATIC flag tells SQLite that the string will remain valid for the lifetime of the call, so it does not need to make its own copy; if you are working with a buffer that might be freed or modified, use SQLITE_TRANSIENT instead so SQLite copies the data internally.
Always remember to call sqlite3_finalize() once you are done with a prepared statement. Failing to do so is a common source of memory leaks in C programs that use SQLite.
Querying Data and Stepping Through Results
Reading data back follows a similar pattern, but instead of expecting SQLITE_DONE after one call to sqlite3_step(), you loop, calling it repeatedly until it returns something other than SQLITE_ROW.
const char *select_sql = "SELECT id, name, department, salary FROM employees;";
sqlite3_stmt *stmt;
rc = sqlite3_prepare_v2(db, select_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
fprintf(stderr, "Failed to prepare statement: %s\n", sqlite3_errmsg(db));
return 1;
}
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
int id = sqlite3_column_int(stmt, 0);
const unsigned char *name = sqlite3_column_text(stmt, 1);
const unsigned char *department = sqlite3_column_text(stmt, 2);
double salary = sqlite3_column_double(stmt, 3);
printf("ID: %d, Name: %s, Department: %s, Salary: %.2f\n",
id, name, department, salary);
}
if (rc != SQLITE_DONE) {
fprintf(stderr, "Error while reading rows: %s\n", sqlite3_errmsg(db));
}
sqlite3_finalize(stmt);
Each call to sqlite3_step() advances to the next row, returning SQLITE_ROW as long as there is data. The sqlite3_column_*() functions extract values from the current row based on the zero-indexed column position. It is worth mentioning that these column-reading functions know the type of data they are extracting only because you tell them; SQLite does not enforce that column 1 must always be text, for instance, since it uses dynamic typing.
Using a Callback With sqlite3_exec
If you prefer a more compact style, sqlite3_exec() also supports a callback function that gets invoked once per row:
static int callback(void *not_used, int argc, char **argv, char **col_names) {
for (int i = 0; i < argc; i++) {
printf("%s = %s\n", col_names[i], argv[i] ? argv[i] : "NULL");
}
printf("\n");
return 0;
}
rc = sqlite3_exec(db, "SELECT * FROM employees;", callback, NULL, &err_msg);
This approach is convenient for quick scripts, but it does have limitations: all values arrive as strings, and it is generally slower and less flexible than working with prepared statements directly, especially for parameterized queries.
Handling Transactions in C
Just like in any other SQLite interface, you can control transactions explicitly for better performance and consistency when performing multiple related writes:
sqlite3_exec(db, "BEGIN TRANSACTION;", NULL, NULL, NULL);
for (int i = 0; i < 1000; i++) {
sqlite3_bind_text(stmt, 1, "Bulk Insert", -1, SQLITE_STATIC);
sqlite3_step(stmt);
sqlite3_reset(stmt);
}
sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL);
Wrapping many inserts in a single transaction dramatically improves performance, since SQLite normally commits (and syncs to disk) after every statement by default. I have seen bulk insert operations go from taking minutes to taking a fraction of a second simply by wrapping them in a transaction like this. Note the use of sqlite3_reset() here, which allows you to reuse a prepared statement for another execution after binding new parameter values, without having to prepare it all over again.
Error Handling
Almost every SQLite C API function returns an integer result code. SQLITE_OK means success, and there is a long list of other codes for various error conditions, like SQLITE_BUSY, SQLITE_CONSTRAINT, SQLITE_ERROR, and so on. You should check these return codes consistently, and use sqlite3_errmsg(db) to get a human-readable description of what went wrong.
if (rc != SQLITE_OK) {
fprintf(stderr, "SQLite error (%d): %s\n", rc, sqlite3_errmsg(db));
}
I make it a habit to wrap common operations, like preparing and stepping through a statement, into small helper functions that check the return code and log meaningful errors. It saves a lot of repetitive boilerplate throughout a larger codebase.
Using the API From C++
While SQLite’s API is pure C, it works perfectly fine from C++ as well. Many developers wrap the raw C functions in a small RAII class to handle cleanup automatically:
#include <sqlite3.h>
#include <stdexcept>
#include <string>
class SQLiteConnection {
public:
explicit SQLiteConnection(const std::string &filename) {
if (sqlite3_open(filename.c_str(), &db) != SQLITE_OK) {
throw std::runtime_error(sqlite3_errmsg(db));
}
}
~SQLiteConnection() {
sqlite3_close(db);
}
sqlite3 *handle() const { return db; }
private:
sqlite3 *db = nullptr;
};
Wrapping the connection in a class like this means the destructor automatically closes the database when the object goes out of scope, which is a lot safer than relying on manual cleanup calls scattered throughout your code. You can build similar RAII wrappers around prepared statements to guarantee sqlite3_finalize() is always called, even if an exception is thrown partway through.
Common Functions You Will Use Often
Here is a quick reference of the functions I use most frequently:
sqlite3_open()/sqlite3_open_v2()— open a database connectionsqlite3_close()— close a connectionsqlite3_prepare_v2()— compile a SQL statement into a prepared statement objectsqlite3_bind_int(),sqlite3_bind_double(),sqlite3_bind_text(),sqlite3_bind_blob(),sqlite3_bind_null()— bind parameter valuessqlite3_step()— execute a statement or move to the next result rowsqlite3_column_int(),sqlite3_column_double(),sqlite3_column_text(),sqlite3_column_blob()— extract column valuessqlite3_reset()— reset a prepared statement so it can be re-executedsqlite3_finalize()— destroy a prepared statement and free its resourcessqlite3_exec()— a convenience function for simple SQL executionsqlite3_errmsg()— retrieve the last error message as textsqlite3_last_insert_rowid()— get the row ID of the most recently inserted row
Best Practices
Always check return codes. It is tempting to skip error checking in small scripts, but in any real application, silently ignoring a failed sqlite3_step() call can lead to subtle and hard-to-diagnose bugs.
Use prepared statements for anything involving parameters, both for performance (since the query only needs to be compiled once and can be reused with different bound values) and for safety, since bound parameters are never interpreted as SQL syntax, which completely eliminates SQL injection risk for that data.
Wrap batches of writes in explicit transactions. This is one of the single biggest performance wins available to you.
Always finalize your prepared statements and close your connections, ideally using RAII patterns in C++ to guarantee cleanup even when exceptions occur.
Enable foreign key enforcement explicitly if you need it, using PRAGMA foreign_keys = ON; right after opening the connection, since it is off by default for backward compatibility reasons.
Consider thread safety. SQLite can be compiled in different threading modes. If your application uses multiple threads accessing the same connection, make sure you understand which threading mode your build of SQLite uses, and consider using a separate connection per thread if you are unsure.
Wrapping Up
The SQLite C/C++ API might feel more verbose than working with a high-level wrapper in Python or another language, but that verbosity buys you precise control over exactly what is happening at every step. Once you understand the core cycle of preparing a statement, binding parameters, stepping through results, and finalizing, you have the foundation to build anything from a small command-line tool to an embedded database layer inside a much larger C or C++ application. From here, it is worth exploring SQLite’s built-in functions, understanding date and time handling, and learning how features like the AUTOINCREMENT keyword actually work at the storage level, since all of that knowledge builds directly on the concepts covered here.