symfony/http-foundation · error · RuntimeException

Failed to read session: INSERT reported a duplicate id but…

Error message

Failed to read session: INSERT reported a duplicate id but next SELECT did not return any data.

What it means

This RuntimeException is thrown by PdoSessionHandler::doRead() as an internal invariant check during transactional session locking. When a session row does not exist, the handler optimistically INSERTs a placeholder row to block concurrent writers; if that INSERT fails with a SQLSTATE 23x duplicate-key error (meaning another connection just created the row), the handler retries the SELECT expecting to find that row. If the SELECT still returns nothing, the handler's assumptions are broken — the row was deleted between the duplicate-key error and the re-read — so it rolls back and throws rather than returning corrupted or empty data.

Solutions

  1. Enable session.use_strict_mode=1 in php.ini (ini_set before session_start) so this non-strict concurrency path in doRead() is skipped entirely and new ids are always unique random values.
  2. Eliminate concurrent destructive operations on the same session id: avoid calling session_destroy()/session_regenerate_id(true) racing with parallel requests, or serialize requests per session (e.g. default session locking files before switching to PDO, or application-level locking).
  3. Stop external jobs from deleting rows of live sessions: exclude rows whose session_time is within session.gc_maxlifetime, or pause GC while requests run.
  4. Catch the \RuntimeException around session_start()/session read and recover by regenerating a fresh session id and restarting the request flow.
  5. Ensure the table actually has the primary/unique key on the id column as the schema requires (the duplicate-key path depends on it), using the CREATE TABLE from the handler docs or createTable().

Example fix

// before (php.ini)
session.use_strict_mode = 0

// after (bootstrap, before session_start)
ini_set('session.use_strict_mode', '1');
Defensive patterns

Strategy: try-catch

Validate before calling

// before session_start
if (!filter_var(ini_get('session.use_strict_mode'), FILTER_VALIDATE_BOOL)) {
    trigger_error('Enable session.use_strict_mode for PDO session handler transactional locking', E_USER_WARNING);
}

Try / catch

try {
    $session->start();
} catch (\RuntimeException $e) {
    if (str_contains($e->getMessage(), 'INSERT reported a duplicate id')) {
        $session->invalidate(); // drop raced session, start fresh
        $session->start();
    } else {
        throw $e;
    }
}

Prevention

When it happens

Trigger: Using PdoSessionHandler with lock_mode LOCK_TRANSACTIONAL while session.use_strict_mode=0 (non-strict mode), when: (1) a concurrent request that had the same session id destroys the session (e.g. session_destroy() / doDestroy) between the failed INSERT and the retry SELECT; (2) heavy concurrency races on the same session id where one connection deletes the row while another is re-reading after its duplicate-key error; (3) external garbage collection or manual DELETE removes the row in that window.

Common situations: High-concurrency PHP-FPM setups with many parallel AJAX requests sharing one session id; apps that call session_regenerate_id(true) or session_destroy() concurrently with in-flight requests; load tests hammering the same session; mixing the PDO session table with external cleanup cron jobs that delete session rows while requests are in flight.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of symfony/http-foundation@5aea19cd67 (2026-09-13). Data as JSON: /api/errors/74c0603f777cbf0d. Report an issue: GitHub.

Appendix: source

Thrown at Session/Storage/Handler/PdoSessionHandler.php:718

        while (true) {
            $selectStmt->execute();
            $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM);

            if ($sessionRows) {
                $expiry = (int) $sessionRows[0][1];

                if ($expiry < time()) {
                    $this->sessionExpired = true;

                    return '';
                }

                return \is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
            }

            if (null !== $insertStmt) {
                $this->rollback();
                throw new \RuntimeException('Failed to read session: INSERT reported a duplicate id but next SELECT did not return any data.');
            }

            if (!filter_var(\ini_get('session.use_strict_mode'), \FILTER_VALIDATE_BOOL) && self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) {
                // In strict mode, session fixation is not possible: new sessions always start with a unique
                // random id, so that concurrency is not possible and this code path can be skipped.
                // Exclusive-reading of non-existent rows does not block, so we need to do an insert to block
                // until other connections to the session are committed.
                try {
                    $insertStmt = $this->getInsertStatement($sessionId, '', 0);
                    $insertStmt->execute();
                } catch (\PDOException $e) {
                    // Catch duplicate key error because other connection created the session already.
                    // It would only not be the case when the other connection destroyed the session.
                    if (str_starts_with($e->getCode(), '23')) {
                        // Retrieve finished session data written by concurrent connection by restarting the loop.
                        // We have to start a new transaction as a failed query will mark the current transaction as
                        // aborted in PostgreSQL and disallow further queries within it.
                        $this->rollback();

View on GitHub (pinned to 5aea19cd67)