Most Cheerio surprises come from one of a handful of causes. This page collects them, roughly in the order people hit them.
My selection is empty
$('…') never throws when nothing matches — it returns an empty selection, and
every method you chain onto it quietly returns an empty result. text() gives
you '', attr() gives you undefined. So the first thing to check is
length:
const $ = cheerio.load('<div class="post"><h1>Title</h1></div>');
console.log($('h1').length); // 1 — found it
console.log($('h2').length); // 0 — no match
console.log($('h2').text()); // '' — not an errorOnce you know the selection is empty, the usual causes are:
The content is rendered by JavaScript. Cheerio does not run scripts. If the markup you’re looking for is built client-side by React, Vue, or similar, it isn’t in the HTML Cheerio received. Check what you actually got:
console.log($.html().slice(0, 500));
If you see an empty <div id="root"> and a bundle <script>, you need
Puppeteer or
Playwright rather than Cheerio.
The class or id is dynamic. Build-tool-generated names like
css-1x2y3z change on every deploy. Prefer stable anchors: data- attributes,
element structure, or text with :contains().
You’re scoping to the wrong element. Inside find() and inside an
extract nested value, selectors run relative to the
current selection, not the document.
text() gives me JavaScript or CSS
text() returns the raw textContent, which includes the source of any
<script> and <style> elements inside the selection. Use
.prop('innerText'), which skips both. It works from the tree alone, though —
Cheerio applies no CSS, so display: none content still comes through:
const $ = cheerio.load(`
<div>
Hello
<style>p { color: red }</style>
<script>var x = 1;</script>
</div>
`);
console.log(JSON.stringify($('div').text()));
console.log(JSON.stringify($('div').prop('innerText')));text() is full of whitespace
Cheerio preserves the document’s whitespace exactly, including the newlines and indentation between tags. Browsers collapse that when rendering; Cheerio does not. Trim it yourself:
const $ = cheerio.load(`
<li>
Apple
</li>
`);
console.log(JSON.stringify($('li').text()));
console.log(JSON.stringify($('li').text().trim()));Note that .prop('innerText') does not help here — it skips <script> and
<style> content, but it preserves whitespace just like text() does.
text() on many elements runs them together
Reading text() from a selection concatenates every match with no separator, so
$('li').text() on three list items gives you one run-on string. Map over the
selection when you want them separately:
const $ = cheerio.load('<ul><li>One</li><li>Two</li><li>Three</li></ul>');
console.log($('li').text()); // 'OneTwoThree'
console.log($('li').map((i, el) => $(el).text()).get());$.html() gives me <html><head></head><body>…
load treats its input as a complete document and adds the missing structure,
just like a browser. Pass false as the third argument to parse a fragment
instead — see fragment mode.
attr('href') returns a relative path
attr() returns the literal attribute value. Use prop('href') to get it
resolved against the document’s URL — and make sure the document has one, either
by loading it with fromURL or by passing the
baseURI option.
My changes don’t show up
Cheerio methods that return a new selection — find, filter, parent, and
the rest of the traversal methods — never modify the
original. Manipulation methods do modify in place, but they modify the tree, not
any string you extracted earlier. Re-serialize with $.html() after making
changes.
Selectors that work in the browser fail here
Cheerio supports jQuery’s positional extensions (:first, :last, :eq(n)),
which are not valid CSS. The reverse also holds: pseudo-classes that depend on
rendering or user state mean nothing in a document that is never rendered — but
they don’t all fail the same way, which matters when you’re debugging.
Some are recognised and simply match nothing:
$('a:hover').length; // 0
$('a:visited').length; // 0
$('a:active').length; // 0
Others aren’t implemented at all and throw:
$('a:focus'); // Error: Unknown pseudo-class :focus
$('a:target'); // Error: Unknown pseudo-class :target
$('a:lang(en)'); // Error: Unknown pseudo-class :lang
So an Unknown pseudo-class error means the selector isn’t supported, whereas an
empty result from :hover means it is supported and genuinely matched nothing.
See Selecting Elements.
Looking something up by a value from data
If a class name, id, or attribute value comes from data rather than being
hard-coded, it may contain characters the selector parser treats specially — a
., :, or space breaks an id selector, and a " breaks even a quoted
attribute selector. Rather than trying to escape it, match on a fixed selector
and compare the value as data:
// Fragile — `.`, `:`, or a space in `id` changes what this selects
$(`#${id}`);
// Still fragile — a `"` in `id` closes the attribute and injects new syntax
$(`[id="${id}"]`);
// Robust — the value is never part of the selector
$('[id]').filter((_, el) => $(el).attr('id') === id);
If the value comes from an untrusted source this is a security issue, not just a correctness one.
Still stuck?
Search the issue tracker, and include a minimal document plus the selector you tried when opening a new one.