How to Connect to SQL Server from Rust
Rust's SQL Server story is short: there is exactly one native driver. tiberius implements the TDS protocol in pure Rust, and everything else in the ecosystem is either a pooling wrapper around it or a detour through ODBC. If you arrived here from an older tutorial showing sqlx with an mssql feature, that code no longer compiles -- sqlx removed its MSSQL driver in 0.7, and the replacement is a paid offering that hasn't shipped. This guide covers what actually works.
The options
| Library | What it is | Status | Version (as of July 2026) |
|---|---|---|---|
tiberius | Native TDS driver, pure Rust, async, runtime-independent | The only native driver; maintained by Prisma, release cadence is slow (0.12.3 shipped July 2024) | 0.12.3 |
bb8-tiberius | Pool manager for tiberius on bb8 | Community | 0.16.0 |
deadpool-tiberius | Pool manager for tiberius on deadpool | Community | 0.1.9 |
odbc-api | Generic ODBC bindings over Microsoft's ODBC Driver 18 | Actively maintained | 29.0.0 |
sqlx | -- | MSSQL support removed in 0.7 | n/a |
The short answer: use tiberius, add bb8-tiberius or deadpool-tiberius when you need a pool, and reach for odbc-api only if you need something tiberius doesn't do (or your ops story is already built around Microsoft's ODBC driver).
A word on maintenance before you commit: tiberius's last crates.io release was July 2024, and repository activity since has been sparse. It's stable and widely used (the Prisma engine depended on it), but if a slow release cadence on your database driver is a dealbreaker, factor that in now rather than in production.
The sqlx trap
Old blog posts and Stack Overflow answers show sqlx = { features = ["mssql"] }. That worked up to sqlx 0.6. The 0.7 changelog is explicit: the MSSQL driver was deleted from the source tree, to return as part of the commercial "SQLx Pro" initiative -- which, as of July 2026, has not shipped a public release. sqlx 0.9.0 has no mssql feature at all. There is no community fork worth using. If you want compile-time-checked queries against SQL Server from Rust today, you don't get them; that's the honest state of things.
Connecting with tiberius
Tiberius is runtime-independent: it doesn't open the TCP connection itself. You create the socket with your runtime and hand it over. With tokio that means one extra detail -- tiberius uses the futures I/O traits, so the tokio stream needs the compatibility adapter from tokio-util:
[dependencies]
tiberius = "0.12"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] }use tiberius::{Client, Config, AuthMethod};
use tokio::net::TcpStream;
use tokio_util::compat::TokioAsyncWriteCompatExt;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut config = Config::new();
config.host("localhost");
config.port(1433);
config.database("mydb");
config.authentication(AuthMethod::sql_server("app", "secret"));
config.trust_cert(); // dev only -- see the TLS section
let tcp = TcpStream::connect(config.get_addr()).await?;
tcp.set_nodelay(true)?;
let mut client = Client::connect(config, tcp.compat_write()).await?;
let rows = client
.simple_query("SELECT name FROM sys.databases")
.await?
.into_first_result()
.await?;
for row in rows {
let name: &str = row.get(0).unwrap();
println!("{name}");
}
Ok(())
}If you already have an ADO.NET or JDBC connection string (from an existing .NET service, say), Config::from_ado_string and Config::from_jdbc_string parse those directly instead of the builder calls.
Parameterized queries: @P1, not ?
SQL Server's wire protocol uses positional @P1, @P2, ... markers -- not ? like MySQL or $1 like PostgreSQL:
use tiberius::Query;
let mut select = Query::new("SELECT id, email FROM users WHERE created_at > @P1 AND plan = @P2");
select.bind("2026-01-01");
select.bind("pro");
let rows = select.query(&mut client).await?.into_first_result().await?;
for row in rows {
let id: i32 = row.get("id").unwrap();
let email: Option<&str> = row.get("email"); // nullable column -> Option is the honest type
println!("{id} {email:?}");
}row.get returns Option<T> and panics on a type mismatch; row.try_get returns a Result if you'd rather handle it. For statements that don't return rows, use execute, which gives you rows-affected counts:
let result = Query::new("UPDATE users SET plan = @P1 WHERE id = @P2");
// ... bind ...
let res = result.execute(&mut client).await?;
println!("updated {} rows", res.total());Generated keys: OUTPUT INSERTED, not last-insert-id
There is no last_insert_id() in TDS. The T-SQL idiom is OUTPUT INSERTED, which also happens to be the most race-free option:
let mut insert = Query::new("INSERT INTO users (email) OUTPUT INSERTED.id VALUES (@P1)");
insert.bind("new@example.com");
let row = insert.query(&mut client).await?.into_row().await?.unwrap();
let id: i32 = row.get(0).unwrap();Avoid @@IDENTITY (crosses trigger scope) and prefer OUTPUT INSERTED over SCOPE_IDENTITY() in new code.
Date and time types need a feature flag
Tiberius maps datetime2, date, and time columns to Rust types from the time or chrono crates -- but neither feature is on by default. Add features = ["time"] (recommended for new code) or ["chrono"], or every temporal column read will fail to find a matching Rust type. Same story for decimal/numeric: enable rust_decimal or bigdecimal.
TLS: encryption levels and the trust_cert reflex
With the default native-tls feature enabled, tiberius defaults to EncryptionLevel::Required -- everything encrypted, fail if the server can't. That's the right default and matches where Microsoft's own drivers have been heading since ODBC Driver 18 and Microsoft.Data.SqlClient 4.0. The catch is the same one every language hits: a local SQL Server or container ships a self-signed certificate, so the handshake fails certificate validation.
The fixes, in order of decreasing correctness:
- Production: give the server a certificate your system trusts, and change nothing in the client.
- Internal CA:
config.trust_cert_ca("/path/to/ca.pem")-- validate against your own root. - Local development only:
config.trust_cert()-- accepts any certificate. Don't ship it.
Resist the fourth option -- config.encryption(EncryptionLevel::NotSupported) -- which turns encryption off entirely. It exists for legacy servers, not for making errors go away. If you build with no TLS feature at all, tiberius silently lands there by default, so keep native-tls (or swap in rustls) enabled.
One rustls-specific note: rustls validates hostnames strictly and won't accept connecting by raw IP with a hostname certificate. Connect by hostname or stay on native-tls.
Named instances: SQL Browser
HOST\SQLEXPRESS-style named instances listen on dynamic ports, resolved by the SQL Browser service over UDP 1434. Tiberius supports the lookup behind a feature flag:
tiberius = { version = "0.12", features = ["sql-browser-tokio"] }use tiberius::SqlBrowser;
let mut config = Config::new();
config.host("db.internal");
config.instance_name("SQLEXPRESS");
config.authentication(AuthMethod::sql_server("app", "secret"));
let tcp = TcpStream::connect_named(&config).await?; // UDP 1434 lookup, then TCP
let mut client = Client::connect(config, tcp.compat_write()).await?;If UDP 1434 is filtered (common across subnets and in cloud networks), skip discovery: find the instance's actual port and connect to host:port directly. That's the more reliable choice anyway.
Azure SQL
Two Azure-specific notes. First, tiberius supports Entra ID tokens via AuthMethod::aad_token(token) -- you fetch the token yourself (e.g. with the azure_identity crate) and hand it over. Second, certain Azure firewall configurations reply to a login with a redirect; tiberius surfaces this as Error::Routing { host, port }, and you're expected to open a new TcpStream to that address and connect again. Wrap your connect logic to handle it -- the tiberius README shows the pattern.
Windows integrated auth (AuthMethod::windows) works on non-Windows hosts through the integrated-auth-gssapi feature, which needs Kerberos headers installed (libkrb5-dev on Debian/Ubuntu) and a valid TGT. That's an infrastructure project, not a Cargo flag; budget accordingly.
Pooling
Tiberius explicitly declares pooling a non-goal, so bring a pool manager. Two maintained options wrap it:
bb8 = "0.9"
bb8-tiberius = "0.16"let mgr = bb8_tiberius::ConnectionManager::build(
"Server=tcp:localhost,1433;Database=mydb;User Id=app;Password=secret;TrustServerCertificate=true",
)?;
let pool = bb8::Pool::builder().max_size(8).build(mgr).await?;
let mut conn = pool.get().await?;
let rows = conn.simple_query("SELECT 1").await?.into_first_result().await?;deadpool-tiberius (0.1.9) is the equivalent on the deadpool runtime, with a builder that mirrors tiberius's Config. Size the pool deliberately: pool size × number of app replicas should stay comfortably under the server's connection capacity, same arithmetic as every other database.
The ODBC alternative
If tiberius's release cadence worries you, or you need features it lacks (bulk operations beyond bulk_insert, exotic TDS types), the well-trodden fallback is Microsoft's ODBC Driver 18 plus odbc-api (29.0.0, actively maintained). You inherit the ODBC driver install chain (msodbcsql18 package) and its own TLS defaults (Encrypt=yes since Driver 18 -- the same self-signed-certificate trap, spelled TrustServerCertificate=yes in the connection string). arrow-odbc builds on it if your endgame is Arrow record batches for analytics. It's synchronous, C-backed, and boring -- sometimes that's exactly right.
Common errors
| Error | Cause | Fix |
|---|---|---|
old tutorial: sqlx feature mssql does not exist | MSSQL removed in sqlx 0.7 | Use tiberius; there is no sqlx path today |
trait bound / compile error passing TcpStream to Client::connect | Missing futures-compat adapter | tokio_util::compat::TokioAsyncWriteCompatExt and pass tcp.compat_write() |
| certificate validation failure on connect | Encryption Required (default) + self-signed cert | Real cert in prod; trust_cert_ca for internal CA; trust_cert() dev only |
| temporal column read fails to convert | time/chrono feature not enabled | Add features = ["time"] (or chrono) |
| named instance connection refused | Dynamic port, SQL Browser not reachable | sql-browser-* feature + connect_named, or connect to the explicit port |
Error::Routing { host, port } on Azure | Azure firewall redirect | Reconnect to the returned address; see README pattern |
get panics with type mismatch | Column type ≠ requested Rust type | Check the mapping; use try_get for a Result |
| Wrong or missing generated key | @@IDENTITY / client-side assumptions | OUTPUT INSERTED.id on the INSERT |
Which one to pick
- Almost everyone:
tiberius+bb8-tiberiusordeadpool-tiberiusfor pooling. - Existing ODBC infrastructure, or driver-maintenance anxiety:
odbc-apiover Microsoft's ODBC Driver 18. - Compile-time checked queries: not available for SQL Server in Rust today. If that's non-negotiable, it may genuinely be a reason to put this service on PostgreSQL instead.
Once connected, you still need to explore the data. Mako connects to SQL Server with AI-powered autocomplete -- handy for prototyping the T-SQL you're about to freeze into Query::new calls. Try it free at mako.ai.
Skip the terminal. Use Mako.
Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.