Skip to content

Regex Validation — Patterns, Injection, and Testing

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you'll learn about Regex Validation. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Regex validation uses regular expressions to match string input against expected patterns, enabling precise format checks for emails, phone numbers, zip codes, and more.

What You'll Learn

By the end of this lesson, you will write regex patterns for common formats, prevent regex injection attacks, and test regex patterns systematically.

Why It Matters

Regex is the most powerful pattern-matching tool available, but it is also error-prone. A poorly written regex can reject valid data, accept invalid data, or even crash your server through catastrophic Backtracking.

Real-World Use

Durga Antivirus Pro uses regex patterns to match file signatures against known malware databases. A single incorrect regex can cause false positives that block legitimate software.

Regex Validation Flow

flowchart TD
    Input[Raw Input] --> Normalize[Normalize/Trim]
    Normalize --> Pattern[Match Against Pattern]
    Pattern --> Match{Matches?}
    Match -->|Yes| Accept[Accept Input]
    Match -->|No| Reject[Return Error]
    Reject --> Escape{Contains Regex Metacharacters?}
    Escape -->|Yes| Sanitize[Log and Sanitize]

Common Pattern Validators

# common_patterns.py
import re
from typing import Optional

class PatternValidators:
    @staticmethod
    def email(value: str) -> Optional[str]:
        if not isinstance(value, str):
            return "Email must be a string"
        # RFC 5322 simplified pattern
        pattern = r'^[a-zA-Z0-9.!#$%&\'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$'
        if not re.match(pattern, value.strip()):
            return "Invalid email format"
        return None

    @staticmethod
    def phone_us(value: str) -> Optional[str]:
        if not isinstance(value, str):
            return "Phone must be a string"
        cleaned = re.sub(r'[\s\-\(\)\.]', '', value)
        pattern = r'^\+?1?\d{10}$'
        if not re.match(pattern, cleaned):
            return "Invalid US phone (expected 10 digits)"
        return None

    @staticmethod
    def zip_us(value: str) -> Optional[str]:
        if not isinstance(value, str):
            return "ZIP must be a string"
        pattern = r'^\d{5}(-\d{4})?$'
        if not re.match(pattern, value.strip()):
            return "Invalid ZIP code (format: 12345 or 12345-6789)"
        return None

    @staticmethod
    def password_strength(value: str) -> Optional[str]:
        if not isinstance(value, str):
            return "Password must be a string"
        if len(value) < 8:
            return "Minimum 8 characters"
        if not re.search(r'[A-Z]', value):
            return "Must contain uppercase letter"
        if not re.search(r'[a-z]', value):
            return "Must contain lowercase letter"
        if not re.search(r'[0-9]', value):
            return "Must contain digit"
        if not re.search(r'[!@#$%^&*(),.?\":{}|<>]', value):
            return "Must contain special character"
        return None

    @staticmethod
    def uuid(value: str) -> Optional[str]:
        if not isinstance(value, str):
            return "UUID must be a string"
        pattern = r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
        if not re.match(pattern, value.strip()):
            return "Invalid UUID format (xXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)"
        return None

pv = PatternValidators()
tests = [
    ("user@example.com", pv.email),
    ("invalid@", pv.email),
    ("+14155550100", pv.phone_us),
    ("12345", pv.phone_us),
    ("90210", pv.zip_us),
    ("9021", pv.zip_us),
    ("Abc123!@", pv.password_strength),
    ("weak", pv.password_strength),
    ("550e8400-e29b-41d4-a716-446655440000", pv.uuid),
    ("not-a-uuid", pv.uuid),
]

for value, validator in tests:
    error = validator(value)
    status = "VALID" if not error else error
    print(f"  {str(value):45s} -> {status}")

Expected output:

  user@example.com                               -> VALID
  invalid@                                       -> Invalid email format
  +14155550100                                    -> VALID
  12345                                          -> Invalid US phone (expected 10 digits)
  90210                                          -> VALID
  9021                                           -> Invalid ZIP code (format: 12345 or 12345-6789)
  Abc123!@                                       -> VALID
  weak                                           -> Minimum 8 characters
  550e8400-e29b-41d4-a716-446655440000           -> VALID
  not-a-uuid                                     -> Invalid UUID format

Regex Injection Prevention

# regex_injection_prevention.py
import re
from typing import Optional

class RegexInjectionPrevention:
    @staticmethod
    def escape_user_input(pattern: str) -> str:
        escaped = re.escape(pattern)
        return escaped

    @staticmethod
    def safe_search(pattern: str, text: str) -> Optional[str]:
        try:
            if re.search(pattern, text):
                return "Match found"
            return "No match"
        except re.error as e:
            return f"Regex error: {e}"

    @staticmethod
    def validate_against_user_pattern(user_pattern: str, text: str) -> str:
        escaped_pattern = re.escape(user_pattern)
        safe_pattern = f".*{escaped_pattern}.*"
        return RegexInjectionPrevention.safe_search(safe_pattern, text)

untrusted = ".*\n.*(.*)"
safe_pattern = re.escape(untrusted)
print(f"User input:      {untrusted}")
print(f"Escaped:         {safe_pattern}")

# Dangerous: using raw user input as regex
dangerous = RegexInjectionPrevention.safe_search(f".*{untrusted}.*", "test")
print(f"Dangerous match: {dangerous}")

# Safe: using escaped pattern
safe_result = RegexInjectionPrevention.safe_search(f".*{safe_pattern}.*", "test")
print(f"Safe match:      {safe_result}")

# ReDoS protection: limit pattern complexity
def safe_user_search(user_pattern: str, text: str, max_length: int = 100) -> str:
    if len(user_pattern) > max_length:
        return "Pattern too long"
    return RegexInjectionPrevention.validate_against_user_pattern(user_pattern, text)

print(f"Long pattern:    {safe_user_search('a' * 200, 'test')}")

Expected output:

User input:      .*
(.*)
Escaped:         \.\*\n\(\.\*\)
Dangerous match: Match found
Safe match:      No match
Long pattern:    Pattern too long

Regex Testing Framework

# regex_tester.py
import re
from typing import Any, Dict, List, Tuple

class RegexTester:
    def __init__(self, pattern: str):
        try:
            self.compiled = re.compile(pattern)
            self.valid = True
        except re.error as e:
            self.compiled = None
            self.valid = False
            self.error = str(e)

    def test(self, cases: List[Tuple[str, bool]]) -> Dict[str, Any]:
        results = {"passed": [], "failed": [], "error": None}
        if not self.valid:
            results["error"] = self.error
            return results
        for value, expected in cases:
            actual = bool(self.compiled.match(value))
            entry = {"value": value, "expected": expected, "actual": actual}
            if actual == expected:
                results["passed"].append(entry)
            else:
                results["failed"].append(entry)
        return results

    def catastrophic_backtracking_risk(self) -> bool:
        # Detect nested quantifiers that cause catastrophic backtracking
        if not self.valid:
            return False
        pattern = self.compiled.pattern
        risk_patterns = [r'\([^)]*\)\*\+\?', r'\[\^?\]\*\+', r'\(\.\*\)\{']
        for risk in risk_patterns:
            if re.search(risk, pattern):
                return True
        return False

tester = RegexTester(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
cases = [
    ("user@example.com", True),
    ("missing-at", False),
    ("user@.com", False),
    ("user@domain.c", False),
]
result = tester.test(cases)
print(f"Passed: {len(result['passed'])}")
print(f"Failed: {len(result['failed'])}")
for f in result["failed"]:
    print(f"  FAIL: {f['value']} expected={f['expected']} actual={f['actual']}")

risk_tester = RegexTester(r'^(a|aa)+$')
print(f"Catastrophic backtracking risk: {risk_tester.catastrophic_backtracking_risk()}")

safe_tester = RegexTester(r'^[a-z]{3,10}$')
print(f"Safe pattern risk: {safe_tester.catastrophic_backtracking_risk()}")

Expected output:

Passed: 3
Failed: 1
  FAIL: user@domain.c expected=False actual=True
Catastrophic backtracking risk: True
Safe pattern risk: False

Pattern Builder

# pattern_builder.py
from typing import Dict, List, Optional

class PatternBuilder:
    def __init__(self):
        self.parts: List[str] = []
        self.anchors: bool = True
        self.case_sensitive: bool = True

    def start(self) -> 'PatternBuilder':
        self.parts = []
        return self

    def literal(self, text: str) -> 'PatternBuilder':
        import re
        self.parts.append(re.escape(text))
        return self

    def digit(self, count: int = 1) -> 'PatternBuilder':
        self.parts.append(f'\\d{{{count}}}')
        return self

    def letter(self, count: int = 1) -> 'PatternBuilder':
        self.parts.append(f'[a-zA-Z]{{{count}}}')
        return self

    def optional(self, pattern: str) -> 'PatternBuilder':
        self.parts.append(f'({pattern})?')
        return self

    def group(self, pattern: str) -> 'PatternBuilder':
        self.parts.append(f'({pattern})')
        return self

    def any_of(self, chars: str) -> 'PatternBuilder':
        safe_chars = chars.replace('[', '').replace(']', '')
        self.parts.append(f'[{safe_chars}]')
        return self

    def build(self) -> str:
        pattern = ''.join(self.parts)
        if self.anchors:
            pattern = '^' + pattern + '$'
        return pattern

    def separator(self, char: str = '-') -> 'PatternBuilder':
        self.parts.append(re.escape(char))
        return self

builder = PatternBuilder()
# Build a US phone pattern: ^\d{3}-\d{3}-\d{4}$
phone_pattern = (builder.start()
    .digit(3).separator().digit(3).separator().digit(4)
).build()
print(f"Phone pattern:    {phone_pattern}")

# Build a date pattern: ^\d{4}-\d{2}-\d{2}$
date_pattern = (PatternBuilder().start()
    .digit(4).literal('-').digit(2).literal('-').digit(2)
).build()
print(f"Date pattern:     {date_pattern}")

# Build a product code: ^[A-Z]{2}-\d{4}$
code_pattern = (PatternBuilder().start()
    .any_of("ABCDEFGHIJKLMNOPQRSTUVWXYZ").digit(4)
).build()
print(f"Product code:     {code_pattern}")

Expected output:

Phone pattern:    ^\d{3}-\d{3}-\d{4}$
Date pattern:     ^\d{4}-\d{2}-\d{2}$
Product code:     ^[ABCDEFGHIJKLMNOPQRSTUVWXYZ]\d{4}$

Common Mistakes

1. Missing Anchors

A pattern [a-z]+ matches anywhere in the string. Always use ^...$ for full-string validation.

2. Catastrophic Backtracking

Patterns like (a|aa)+$ or (.*)* cause exponential backtracking on near-matches. Use atomic groups or possessive quantifiers.

3. Not Escaping User Input

Passing untrusted input directly to re.compile() or re.match() allows regex injection. Always escape user-supplied patterns.

4. Overly Complex Patterns

A 200-character regex is harder to test, maintain, and understand. Break complex patterns into smaller validators.

5. Case Sensitivity Mismatch

Forgetting to set re.IGNORECASE when validating emails or URLs can cause false rejections.

Practice Questions

1. What does the ^ and $ anchor do in regex?

^ matches the start of the string, $ matches the end. Together they ensure the entire string matches.

2. What is catastrophic backtracking?

A regex engine trying exponentially many ways to match nested quantifiers, potentially freezing or crashing.

3. How do you prevent regex injection?

Escape user input with re.escape() before incorporating it into a pattern. Never pass raw input to re.compile().

4. What is the difference between re.match() and re.search()?

re.match() checks only the beginning of the string. re.search() checks anywhere. Use match with anchors for full validation.

Challenge

Build a pattern library that validates: email, US phone, international phone (E.164), ZIP+4, MAC address, IPv4, IPv6, UUID, base64, and hex color. Include a test suite with edge cases.

FAQ

What is catastrophic backtracking in regex?

Nested quantifiers like (a|aa)+$ cause exponential backtracking on non-matching strings, potentially freezing the regex engine.

Should I validate email with regex?

A simple pattern checking for @ and domain is sufficient. Full RFC 5322 compliance requires a parser, not a regex.

How do I validate international phone numbers?

Use the E.164 format and a digit count check. For full validation, use a library like google-libphonenumber.

What is a safe regex for password validation?

Check length (8+), uppercase, lowercase, digit, and special character separately. Do not use a single monolithic pattern.

How do I test regex patterns?

Define test cases with expected true/false results. Use a regex tester class to automate validation of each pattern.

Mini Project: Regex Validator Library

# regex_validator_lib.py
import re
from typing import Callable, Dict, List, Optional, Tuple

class RegexValidator:
    def __init__(self):
        self.patterns: Dict[str, Tuple[str, Optional[str]]] = {}

    def add(self, name: str, pattern: str, flags: int = 0, error_msg: Optional[str] = None):
        try:
            re.compile(pattern, flags)
        except re.error as e:
            raise ValueError(f"Invalid regex for {name}: {e}")
        self.patterns[name] = (pattern, error_msg or f"Invalid {name}")

    def validate(self, name: str, value: str) -> Optional[str]:
        entry = self.patterns.get(name)
        if not entry:
            return f"Unknown pattern: {name}"
        pattern, message = entry
        if not re.match(pattern, value.strip()):
            return message
        return None

    def validate_all(self, rules: Dict[str, str]) -> Dict[str, str]:
        errors = {}
        for name, value in rules.items():
            error = self.validate(name, value)
            if error:
                errors[name] = error
        return errors

rv = RegexValidator()
rv.add("email", r'^[^@]+@[^@]+\.[^@]+$', error_msg="Invalid email format")
rv.add("zip_us", r'^\d{5}(-\d{4})?$', error_msg="Invalid ZIP code")
rv.add("hex_color", r'^#[0-9a-fA-F]{6}$')

print(rv.validate("email", "user@example.com"))
print(rv.validate("email", "bad"))
print(rv.validate_all({"email": "user@example.com", "zip_us": "90210"}))
print(rv.validate_all({"email": "bad", "zip_us": "902"}))

Expected output:

None
Invalid email format
{}
{'email': 'Invalid email format', 'zip_us': 'Invalid ZIP code'}

What's Next

You understand regex validation. Next, learn cross-field validation, then custom validation rules.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro