← Back to blog

"Cannot modify header information - headers already sent by": read the path, then check output_buffering

The SandyWP team 5 min read

"Cannot modify header information - headers already sent by" means PHP printed something to the browser before WordPress tried to send a header, such as a redirect or a login cookie. The file to fix is the one in brackets after output started at, at the line number given there, not the WordPress file named after "in".

Most of the time that file has a blank line or a stray byte before <?php, a gap after ?>, or a displayed PHP warning above the real failure.

The part the usual guides skip is why the warning shows up on one server and not another with identical code. That comes down to one php.ini setting, output_buffering, and it is the reason this error so often appears straight after a migration.

How do I read the warning?

Here is a typical one from WordPress 7.1:

Warning: Cannot modify header information - headers already sent by
(output started at /var/www/html/wp-content/themes/mytheme/functions.php:1)
in /var/www/html/wp-includes/pluggable.php on line 1542

It names two places, and they mean different things.

Output started at is where the first byte of the response body was produced. That is your bug: functions.php, line 1.

In ... on line ... is the code that tried to send a header too late. In WordPress 7.1, pluggable.php line 1542 is the Location: header inside wp_redirect().

Lines 1193 to 1197 are the setcookie() calls in wp_set_auth_cookie().

Those files are almost never what needs editing.

The exact sentence is built in PHP's own main/SAPI.c.

When PHP cannot tell where output began, it prints the shorter "Cannot modify header information - headers already sent" with no path, which means PHP could not tie the output to a file and line.

Look at PHP startup errors and the server error log first.

The setcookie() case explains a symptom people rarely connect to this warning. If it fires on wp-login.php, the auth cookie is never set, so the login form just reloads with no error.

What counts as output?

Anything PHP sends that is not inside a PHP block. In order of how often we see them:

  1. Whitespace or a blank line before <?php in a file loaded early: wp-config.php, a plugin's main file, a must-use plugin, or a theme's functions.php.
  2. A UTF-8 byte order mark. Some editors save three invisible bytes (EF BB BF) at the start of the file. They look like nothing in an editor and still count as output.
  3. Whitespace after a closing ?>. PHP swallows exactly one newline straight after ?>, so ?> followed by a single line break at the end of a file is harmless. A second blank line, or a space, is output.
  4. A displayed PHP warning or deprecation. The warning text is itself output, so "output started at" points at the line that raised it, which can look perfectly innocent.
  5. A leftover echo, print_r or var_dump in a plugin or theme.

The fourth one deserves a test, because it is the one that sends people hunting for whitespace that is not there. This file has no stray bytes at all:

<?php
$a = [];
$x = $a['missing'];
header( 'Location: /done' );

With display_errors on, PHP 8.3 prints:

Warning: Undefined array key "missing" in n.php on line 3

Warning: Cannot modify header information - headers already sent by (output started at n.php:3) in n.php on line 4

Line 3 is the culprit, but the problem is a warning, not formatting. If there is a notice or deprecation directly above the headers warning on the page, fix or hide that one first and the second warning disappears with it.

Why did it start after moving hosts?

Because your old host was buffering output, and the new one is not.

With output_buffering set, PHP holds the start of the response in memory and only sends headers when the buffer fills or the script ends. A stray blank line sits in the buffer, the redirect header still goes out, and nothing looks wrong.

With buffering off, that blank line is sent immediately and every header after it fails.

The setting has two different defaults, which is the whole trap. PHP's compiled-in default is 0, which means off (main/main.c).

The php.ini-production file that ships with PHP sets it to 4096 bytes, so a server configured from that file buffers and a server with no php.ini does not.

We checked this on PHP 8.3.6, with a file whose only defect is a blank first line before a header( 'Location: /done' ) call:

output_buffering Response
4096 302 Found, Location: /done. Looks fine.
0 200 OK, the headers warning in the body, no redirect.

Same file, same PHP version, opposite result. The code was always broken; the old server was hiding it.

The buffer also has a size.

Using the same 4096 setting, a script that echoes 5,000 bytes before calling header() fails exactly as if buffering were off. That is why some sites show this warning only on long pages.

Plenty of servers ship with buffering off. The official wordpress Docker image, for example, adds its own conf.d files for OPcache and error logging but does not install php.ini-production, so output_buffering stays at PHP's default of off.

How do I check output_buffering on my server?

Check it through the web server, not on the command line. The PHP CLI hard-codes output_buffering=0 in sapi/cli/php_cli.c, so php -i and wp eval always report off, whatever your site actually runs with.

Drop a temporary file in the web root, load it once in a browser, then delete it:

<?php
header( 'Content-Type: text/plain' );
var_dump( ini_get( 'output_buffering' ), ini_get( 'display_errors' ) );

Run it on both the old and new host if you still have access to the old one. A difference here explains the "it worked before" part on its own.

It can only be set in php.ini, .user.ini or .htaccess (it is PHP_INI_PERDIR), so ini_set() inside WordPress cannot change it.

How do I find the stray bytes?

Start with the path in the warning. Open that file and look at the line it names, and at the very start and very end of the file.

If the editor shows nothing, the cause is probably a byte order mark. This command lists the first bytes of a file, and ef bb bf at the front means a BOM:

head -c 16 wp-config.php | od -An -tx1

To sweep a whole directory, this PHP one-liner reports BOMs, whitespace before <?php, and anything after a closing ?> other than a single newline:

php -r 'foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($argv[1])) as $f) { if (substr($f, -4) !== ".php") continue; $s = file_get_contents($f); if (strncmp($s, "\xEF\xBB\xBF", 3) === 0) echo "BOM: $f\n"; elseif (preg_match("/\A\s+<\?php/", $s)) echo "whitespace before <?php: $f\n"; elseif (preg_match("/\?>(?!\r?\n\z)\s+\z/", $s)) echo "whitespace after ?>: $f\n"; }' wp-content

Run it against wp-content, then against wp-config.php on its own. Theme template files that start with HTML are normal and will not be flagged; the check only looks for whitespace and BOMs.

The fix for each hit is to delete the stray bytes, or in a file that is pure PHP, to delete the closing ?> entirely. Leaving it out makes the third cause impossible.

For a BOM, re-save the file as "UTF-8" rather than "UTF-8 with BOM". If the file belongs to a plugin or theme you did not write, update or reinstall it instead of editing it, or the next update will put the problem back.

Should I just turn output buffering on?

It works, and it is the wrong first move.

Setting output_buffering = 4096, or wrapping code in ob_start(), hides the stray output again.

The bytes still go to every visitor at the top of every page, and a page that outgrows the buffer breaks again.

If a stray byte lands in a REST API or admin-ajax response, it can also corrupt the JSON, which is how this same bug turns into "The response is not a valid JSON response" in the block editor.

Use buffering as a stopgap while you find the file, and remove it afterwards.

The one setting you should change on production is display_errors, because warnings printed to visitors cause the fourth kind of output on this list.

Keep them in a log instead: WP_DEBUG_DISPLAY set to false with WP_DEBUG_LOG on, or display_errors = Off in php.ini.

When WP_DEBUG is off, WordPress leaves display_errors to whatever php.ini says, so check php.ini too.

Reproduce it on a copy, with buffering off

The useful property of an environment with buffering off is that it shows you every one of these bugs, rather than the ones big enough to overflow 4 KB. That makes it a good place to look for them before a migration.

SandyWP sandboxes run on the official wordpress Docker image, and our own PHP overrides set upload, memory and time limits but leave output_buffering alone. So a stray byte that your current host hides will produce the warning in a sandbox.

To check a live site, copy it with the SandyWP Cloner plugin, which only reads the live site and sends a database export and a copy of wp-content. Then:

  • Turn on Debug mode for that sandbox and read debug.log next to the toggles. It records the headers warning with its "output started at" path even when nothing is shown on the page.
  • Run the scanner above over SSH, against wp-content on the sandbox, without touching production.
  • Switch the sandbox between PHP 8.2 and 8.5 if the "output" is a deprecation that only one version prints.

Be clear about one limit: the clone lands on clean WordPress core with its own wp-config.php, so a stray byte in your live wp-config.php or in a modified core file will not come across.

For those, run the head -c 16 check on the live file directly.

The Cloner also runs from wp-admin. If the warning is stopping you logging in, fix the file over SFTP first, then clone.

Quick checklist

  1. Copy the path after output started at. Ignore pluggable.php.
  2. If there is another warning above it, fix that one first.
  3. Look for a blank line or BOM before <?php, and anything after a closing ?>.
  4. Compare output_buffering between old and new hosts through a web request, not the CLI.
  5. Turn display_errors off on production and log instead.
  6. Use buffering as a stopgap only.

If the site also lost its admin after the move, our guide to wp-admin not working covers the redirect and cookie problems that look similar. If the move itself is still ahead of you, the host migration guide covers testing the new server before you switch DNS.