PHP MySQLi: Interpolated Queries Exposed 50k Records
Users saw other users' data in search — no log errors.
- MySQLi is PHP's dedicated extension for talking to MySQL databases
- Supports both procedural and object-oriented styles
- Prepared statements separate SQL logic from data — structurally immune to injection
- Reusing a prepared statement with different parameters can cut query overhead by ~30%
- Forget error reporting mode and your app silently serves stale data
- The biggest mistake: assuming mysql_error() covers every failure — it doesn't for prepared statements
Imagine your PHP app is a waiter at a restaurant. MySQL is the kitchen. MySQLi is the official order-taking system — the notepad, the ticket printer, and the intercom — that lets the waiter talk to the kitchen reliably and safely. Without it, the waiter would just shout random things through the wall and hope for the best. MySQLi gives you a structured, two-way conversation between your code and your database.
Every meaningful web application stores data — user accounts, orders, blog posts, settings. PHP can do a lot on its own, but the moment you need to persist or retrieve that data, you need a database layer. MySQL is the world's most popular open-source database, and MySQLi (MySQL Improved) is PHP's built-in extension for talking to it. It's not an optional nice-to-have — it's the backbone of practically every PHP-powered site you've ever visited.
The original mysql_* functions were deprecated in PHP 5.5 and removed entirely in PHP 7.0. MySQLi replaced them with a more powerful API that supports prepared statements, multiple statements, transactions, and both procedural and object-oriented styles. The single biggest reason it exists is security: the old API made SQL injection trivially easy. MySQLi's prepared statements make it structurally hard to write vulnerable code.
By the end of this article you'll know how to open a database connection the right way, run SELECT and INSERT queries safely using prepared statements, handle errors without leaking sensitive info to users, and fetch results in whatever format your app needs. You'll also understand the traps that catch intermediate developers — including the one that silently corrupts your data.
What is PHP with MySQL — MySQLi?
MySQLi is the 'MySQL improved' extension, offering a modern interface with support for prepared statements, transactions, and multiple statement handling. It replaced the ancient mysql_* functions that were insecure and deprecated. MySQLi can be used in two styles: procedural (like the old extension but improved) and object-oriented (which groups all functions into a class). In production, the OO style is preferred for its cleaner syntax and IDE autocompletion. It's not just a new coat of paint — the prepared statement support is the main event. That's what makes SQL injection structurally impossible.
Here's the thing: many tutorials show you the procedural style because it looks simpler for beginners. But once you start writing real applications, you'll want OOP. You get autocomplete in your IDE, you don't have to pass the connection resource to every function, and the code reads more naturally. The procedural style was a transitional API to help migrate from the old mysql_* functions. Today, there's no reason to start new code in procedural mode.
Always enable error reporting from the start. That single call to mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT) will save you hours of debugging. Without it, a failed returns prepare()false and you get a cryptic "Call to member function on boolean" error. With it, you get a proper exception with a clear message.
$conn->connect_error, your app will attempt queries on a failed connection and return cryptic errors or empty results.mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT) during development to catch every failure immediately.require_once for connection setup prevents the silent failure trap.Connecting to MySQL with MySQLi: The Right Way
Connecting to MySQL looks simple — new mysqli(...) — but the defaults can burn you. By default, MySQLi uses buffered queries (good), connects via TCP (localhost may use socket), and does not throw exceptions on failure. That last one is dangerous.
Always set the connection charset explicitly, especially when dealing with UTF-8 data. A missing charset can corrupt multi-byte characters without any error. Also, decide early whether your app should die on connection failure or handle it gracefully. In production, you probably want to log the error and show a friendly message, not expose the full error to the user.
Here's a production-ready connection pattern using OO style with exception handling. Note the use of try-catch with mysqli_sql_exception. This ensures that every connection issue is caught and logged, while the user sees a generic error page. Never expose database error details to end users — that's a security vulnerability and a gift to attackers.
utf8mb4 charset is the only safe choice for modern applications that handle emoji, special characters, or any non-English text.$conn->connect_error — but if you try to query, you'll get a fatal 'Call to a member function on boolean'.Prepared Statements: How They Block SQL Injection
Prepared statements are the single most important feature of MySQLi. Instead of building SQL strings by concatenating user input, you send a template with placeholders (?) and then send the actual values separately. The database server compiles the query template first, then binds the values. Because the values are never parsed as SQL — they're always treated as literal data — injection becomes impossible.
This doesn't just prevent injection; it also eliminates the need to escape strings (no more ). And for repeated queries, prepared statements can offer performance gains because the query plan is cached.mysqli_real_escape_string()
Here's how to use them correctly with different data types. The bind_param type specifiers are crucial: 'i' for integer, 'd' for double, 's' for string, 'b' for blob. Get them wrong and you can introduce subtle bugs — for example, using 's' for an integer column forces a string-to-integer cast that may ignore the index and cause a full table scan. Always double-check your types, especially when the data comes from user input.
- The envelope (query) is designed before any data arrives.
- Data slides in through the holes — it cannot change the envelope's shape.
- No amount of malicious input can rewrite the SQL because the SQL was compiled first.
- This is fundamentally different from building the envelope out of the input itself.
bind_param with the wrong type specifier: 's' for an integer column. MySQL silently cast the value, but the index was ignored, causing a full table scan on a 10-million-row table.get_result() (mysqlnd required) are simpler for SELECT: no need to manually bind_result and fetch.get_result() when mysqlnd is available — it's cleaner than bind_result.Fetching Results: Choosing the Right Method
MySQLi provides several ways to fetch results from SELECT queries. The most common are , fetch_assoc(), fetch_row(), and fetch_array(). The choice depends on how you plan to use the data and whether you're using buffered or unbuffered mode.fetch_all()
fetch_assoc() returns an associative array — you access columns by name. It's the most readable for most code. fetch_row() returns a numerically indexed array — useful if you know column order. fetch_array() returns both, but that's wasteful in memory. fetch_all() returns all rows at once as an array of arrays — convenient, but can eat memory on large result sets.
For prepared statements, you have two paths: (lower level) or bind_result() (returns a result set like a regular query). In modern PHP with mysqlnd, get_result() is almost always the better choice. The get_result()mysqlnd driver is included by default in PHP since 5.4, but some shared hosting providers still use libmysqlclient. If is not available, you'll get a fatal error. Check with get_result()function_exists('mysqli_get_result').
fetch_all() when you know the result set is small (e.g., a dropdown list of categories). For large datasets, stick to the loop with fetch_assoc() to keep memory usage constant.bind_result() with multiple columns requires naming each variable and calling $stmt->fetch() in a loop — easy to get variable order wrong.get_result() won't be available, and you're stuck with bind_result() or store_result() plus fetch_assoc() on the stored result.phpinfo() or function_exists('mysqli_fetch_all'). In 2026, most hosting providers include it, but verify before relying on get_result().fetch_assoc() in a loop is the most memory-efficient and readable pattern.get_result() simplifies prepared statement fetching — but requires mysqlnd.fetch_array() unless you need both numeric and associative access — it doubles memory.Transactions and Multiple Statements
Transactions ensure that multiple queries are treated as a single unit of work: all succeed or all fail together. MySQLi supports transactions via , begin_transaction(), and commit(). You can also set autocommit off and manage manually.rollback()
Multiple statement execution () allows sending multiple SQL statements in one call. This is useful for batch operations, but dangerous if you're not careful — it's a vector for second-order SQL injection if user input is involved.multi_query()
Prepared statements with transactions: you can prepare once, execute with different parameters in a loop, then commit — all without re-parsing the SQL. This is a common pattern for bulk inserts. But don't forget to close the statement before the next call, or you'll get a "Commands out of sync" error.prepare()
multi_query() with user-supplied data. Even if you validate and escape, the risk of second-order injection is high. If you need to run multiple statements, prepare them individually inside a transaction, or use a stored procedure.multi_query() in a shared hosting environment can exceed the max_allowed_packet limit silently, truncating your SQL.multi_query() in production — it's a maintenance and security liability.Best Practices and Common Production Pitfalls
Even with MySQLi, there are several ways your code can fail in production. Here are the most critical:
- Not using persistent connections incorrectly:
can cause connection pinning issues in some environments. Unless you fully understand the implications, stick to non-persistent connections.mysqli_pconnect() - Reusing a statement without resetting: After
, if you want to use the same prepared statement with new parameters, you must clear the old results withexecute()orfree_result().close() - Ignoring the result of
:execute()returns true on success, but if you're on non-exception mode, it can return false without an obvious error. Always check.execute() - Using
when you should use prepared statements: If you're still escaping strings, you're doing it wrong. Prepared statements handle escaping natively.mysqli_real_escape_string() - Hardcoding credentials in PHP files: Store them outside the web root or in environment variables.
Another one that bites people: mixing up and fetch_assoc() orders. If you have a query with JOINs, the column order in the result set depends on the query, not the table order. Always access by column name (associative) rather than numeric index to avoid bugs when you add columns later.fetch_row()
$stmt variable across different queries without freeing results causes 'Commands out of sync' error.$conn->close() inside a loop that recreates connections kills performance — use a single connection per request.prepare() should be paired with a close() or free_result() before the next prepare.The Silent Data Leak: How Interpolated Queries Exposed 50k Customer Records
SELECT FROM users WHERE name LIKE '%$search%' with no parameter binding. An attacker submitted %' UNION SELECT FROM users -- and the full user table was returned.$stmt = $conn->prepare("SELECT * FROM users WHERE name LIKE CONCAT('%',?,'%')") followed by $stmt->bind_param('s', $search). Enabled exception mode to catch all query failures.- Prepared statements are not optional — they are the only defense against SQL injection that the database can enforce.
- Input validation is a secondary layer, not the primary one.
- Always enable exception reporting during development and monitor for query failures in production.
bind_param() on booleanprepare() succeeded before calling bind_param(). Always verify: if (!$stmt = $conn->prepare(...)) { // handle error }. Use error reporting: mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT).$conn->set_charset('utf8mb4') before queries. A mismatch between connection charset and table charset causes silent failures on string comparisons.mysqli_free_result() or switch back to buffered mode.MYSQLI_USE_RESULT and fetch row by row with fetch_assoc() in a loop. But be careful: this holds server-side cursor and blocks other queries on that connection. Alternatively, paginate with LIMIT.Key takeaways
get_result() over bind_result() when mysqlnd is available.Common mistakes to avoid
5 patternsMemorising syntax before understanding the concept
Skipping practice and only reading theory
bind_param() call without looking it up. When a production bug hits, you can't diagnose because you've never encountered the errors in practice.Using prepared statements but not checking return values
prepare() returns false because of a syntax error, but the script continues to bind_param() on a non-object, producing a fatal error. In non-debug mode, this becomes a white screen.mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT) at the start of your scripts. This forces every database error to throw a mysqli_sql_exception that you can catch and log.Using the wrong charset — or none at all
$conn->set_charset('utf8mb4') immediately after connecting. Also ensure your MySQL tables use the utf8mb4 character set and collation like utf8mb4_unicode_ci.Assuming `mysqli_real_escape_string()` alone is sufficient protection
mysqli_real_escape_string() for query data. Switch to prepared statements exclusively. Escaping is a fallback, not a primary defense. Prepared statements delegate parameter handling to the database, making injection structurally impossible.Interview Questions on This Topic
How does a prepared statement prevent SQL injection at a technical level?
Frequently Asked Questions
That's PHP & MySQL. Mark it forged?
6 min read · try the examples if you haven't