Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

Pass a tag-level predicate to find_all() and return the opposite of has_attr(). For example, soup.find_all('a', lambda tag: not tag.has_attr('target')) returns every link that does not have a target attribute. A tag-level function is important because it receives the complete tag, so it can test both presence and absence safely.

The basic pattern

Define a function that receives a BeautifulSoup Tag and returns True only when the attribute is missing. Then pass that function as the second argument to find_all().

from bs4 import BeautifulSoup

html = '''
No target
Has target
Also no target
'''

soup = BeautifulSoup(html, 'html.parser')

def lacks_target(tag):
    return not tag.has_attr('target')

matches = soup.find_all('a', lacks_target)
for link in matches:
    print(link.get('href'))

The output is /one and /three. Replace 'a' with the element name you need, such as 'img', 'div' or 'input'.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Search every tag type

Omit the name argument when the predicate itself determines which tags qualify:

matches = soup.find_all(lacks_target)

This searches all descendants and returns a list-like ResultSet. If nothing matches, the result is an empty collection rather than an exception.

Why the predicate must receive the whole tag

BeautifulSoup supports callables in two different places, and they receive different inputs:

Form Callable receives Use it for
soup.find_all(predicate) The complete Tag Checking whether attributes exist, combining several attributes, inspecting tag names or reading other tag data
soup.find_all(href=predicate) The value of href Testing an existing attribute value, such as whether a URL contains a substring

An attribute-specific function cannot reliably answer “does this tag lack target?” because it is given an attribute value, not the tag object. Use a tag-level predicate whenever the condition depends on attribute presence or on more than one property.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Useful absence and presence predicates

Require that an attribute is absent

def without_data_id(tag):
    return not tag.has_attr('data-id')

items = soup.find_all('li', without_data_id)

has_attr() tests whether the attribute exists, even if its value is an empty string. The not operator therefore expresses absence directly.

Require one attribute but exclude another

The same shape handles compound rules. This example finds links with a class but no id:

def has_class_but_no_id(tag):
    return tag.has_attr('class') and not tag.has_attr('id')

matches = soup.find_all('a', has_class_but_no_id)

Both conditions must be true. Add more clauses with and when a scraper has stricter requirements.

Match a tag name inside the function

When you omit the name argument, check tag.name to avoid matching unrelated elements:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
def image_without_alt(tag):
    return tag.name == 'img' and not tag.has_attr('alt')

missing_alt = soup.find_all(image_without_alt)

Use a reusable predicate factory

If the attribute name is configurable, return a function that closes over it:

def missing_attribute(attribute_name):
    def predicate(tag):
        return not tag.has_attr(attribute_name)
    return predicate

without_role = soup.find_all(missing_attribute('role'))
without_title = soup.find_all(missing_attribute('title'))

This keeps the matching logic in one place while allowing different audits.

Read optional attributes without exceptions

Finding a tag without an attribute is only half the job; you may then need to inspect another attribute. Avoid subscription such as tag['target'] when the key might not exist. Subscription raises KeyError for a missing attribute.

for link in soup.find_all('a'):
    target = link.get('target')
    href = link.get('href', '')
    print(href, target)

get() returns None by default, or a fallback you provide:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
label = link.get('aria-label', '(no label)')

Use has_attr() when you need to distinguish “missing” from “present with an empty value.” Use get() when you simply need a safe value.

A complete, runnable audit script

The following program reads a local file, reports links lacking target, and separately reports links that have a class but no id. It does not assume either optional attribute exists.

from pathlib import Path
from bs4 import BeautifulSoup

html = Path('page.html').read_text(encoding='utf-8')
soup = BeautifulSoup(html, 'html.parser')

def lacks_target(tag):
    return tag.name == 'a' and not tag.has_attr('target')

def class_without_id(tag):
    return tag.name == 'a' and tag.has_attr('class') and not tag.has_attr('id')

for link in soup.find_all(lacks_target):
    print('Missing target:', link.get('href', '(no href)'))

for link in soup.find_all(class_without_id):
    classes = ' '.join(link.get('class', []))
    print('Class but no id:', classes, link.get('href', '(no href)'))

Install BeautifulSoup with the package that provides the bs4 import, then run the script from the directory containing page.html. Change the parser to one installed in your environment if your input requires it.

find_all(), find() and search scope

Get every match

find_all() traverses descendants and returns all matching tags. Iterating over the result is safe even when there are no matches:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
for tag in soup.find_all('a', lacks_target):
    process(tag)

Get only the first match

Use find() when the first qualifying element is sufficient. It returns a single Tag or None:

first = soup.find('a', lacks_target)
if first is not None:
    print(first.get('href'))

Limit the search to a subtree

Locate a container first, then search only inside it. This reduces accidental matches elsewhere in the document:

main = soup.find('main')
links = main.find_all('a', lacks_target) if main else []

You can also pass recursive=False to search only direct children of a tag:

direct_children = soup.find_all('li', lacks_target, recursive=False)

Use that option when nested descendants should not count.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

CSS selectors as an alternative

BeautifulSoup delegates CSS selection to SoupSieve. A selector such as a:not([target]) expresses the same rule concisely:

links = soup.select('a:not([target])')

Documentation describes support for most CSS4 selectors from BeautifulSoup 4.7.0 onward, through SoupSieve. Check the versions installed in your environment before relying on a newer selector. The callable form is clearer when you need compound Python logic, custom validation, or compatibility with older setups.

When CSS is the better choice

  • Use select() for a short, declarative selector that your team already understands.
  • Use a callable when you must combine several tests, normalize values, or run Python code against the entire tag.
  • Keep a callable for rules that are difficult to express or review as a CSS selector.

Common mistakes and fixes

Indexing a missing attribute

Symptom: KeyError at tag['target'].
Cause: The tag does not contain that attribute.
Fix: Test with has_attr(), or read with get().

Using an attribute-level callable for absence

Symptom: A predicate written as href=predicate cannot inspect the tag or behaves unexpectedly.
Cause: Attribute filters receive the attribute value only.
Fix: Pass the predicate directly to find_all() so it receives the complete tag.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Matching the wrong element types

Symptom: Results include tags you did not intend to audit.
Cause: Calling soup.find_all(predicate) searches every tag.
Fix: Supply the tag name, or check tag.name inside the function.

Confusing an empty value with absence

Symptom: A tag such as <div data-id=""> is treated as if the attribute were missing.
Cause: Testing the value rather than the attribute key.
Fix: Use tag.has_attr('data-id') for presence; use tag.get('data-id') when the value itself matters.

Unexpectedly broad results

Symptom: A valid-looking predicate returns too many matches.
Cause: The search starts at the document root and includes nested widgets, templates or unrelated sections.
Fix: Search from a known container, or use recursive=False for direct children.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Performance and reliability considerations

For ordinary documents, a single find_all() traversal is straightforward and predictable. Keep the predicate cheap: check the tag name and attributes first, and avoid expensive parsing or network work inside it. If you need several reports, consider one pass that classifies tags rather than repeatedly scanning the entire document.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Malformed HTML can produce a tree different from the source text. Choose a parser appropriate to your input and test predicates against representative pages, including tags with empty attributes, duplicated attributes, nested elements and missing containers. Treat a zero-result collection as a normal outcome; decide in your application whether that means “nothing to report” or a validation failure.

Or skip the browser setup

If your broader workflow needs rendered page images rather than parsed HTML, ScreenshotNeo provides a website screenshot API. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups and chat widgets; each step can be disabled. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and the response identifies the page verdict and billing status in X-Page-Verdict and X-Billed headers.

Use the API documentation at https://screenshotneo.com/docs/ for authentication and options. A single GET request is enough:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

The equivalent Python request is:

import requests

r = requests.get(
    'https://api.screenshotneo.com/v1/shot',
    params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'},
    timeout=90,
)
r.raise_for_status()
open('shot.webp', 'wb').write(r.content)

In Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients. One thousand screenshots per month are free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account to get started.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Frequently Asked Questions

Are HTML attribute names case-sensitive in this test?

BeautifulSoup normalizes parsed HTML into its tag and attribute model, but behavior can differ for XML parsing. Use the parser appropriate to your document type and test a sample containing the casing you expect.

Can I combine a missing-attribute test with a value check?

Yes. Return one Boolean expression from the tag-level predicate, checking each required condition with and and excluding conditions with not.

What should I do when the parent container is missing?

Check the result of find() before calling find_all() on it; a missing parent is represented by None.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.