LESSON 117 · DEEPER PYTHON29 min

Capture and Replace Text with Python Regex

Use named capture groups and a re.sub callback to transform complete order codes without changing unrelated text.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Capture and Replace Text with Python Regex

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    Here is a small formatting problem that comes up when a program reads a line of identifiers. We receive four-digit order codes, and we want a friendlier label that drops leading zeros but keeps the numeric value. The code on screen looks plausible: it searches for `ORD-` followed by digits and replaces each occurrence. Let's actually run it. Both code numbers disappear. The regular expression found too much for the chosen fixed replacement. It did not save the part we still need, and the word `order` cannot somehow recover those digits afterward. We also have a second bug that this first example does not reveal.

  2. 00:40

    The `+` permits any number of digits, while our contract is exactly four. A five-digit identifier would be transformed even though it is invalid. These are not reasons to avoid regular expressions entirely. They are reasons to define the data rule precisely before writing the pattern. We will inspect the part of a match we care about, use it to compute replacement text, and then test the edges. The lesson on `re.search` and `re.findall` already showed how to locate matching text. Today we go one step further: we preserve a captured field while changing the surrounding representation. Keep watching the actual output at every step, because two regular expressions that

  3. 01:23

    look nearly identical can accept very different inputs. The parentheses around the digit pattern make a capture group, and the syntax `?P<number>` gives that group a name. The whole match is `ORD-0042`; the named group is just `0042`. When we print it, the leading zeros remain because a regex group returns text, not an integer. That distinction is useful. The pattern should identify which characters are part of a code. A later Python expression can decide whether to convert the captured text, preserve the original padding, or format it in some new way. If I replace the input with a string that contains no order code, `re.search`

  4. 02:04

    returns `None`. I must check that before calling `group`, or I will get an attribute error. In the final solution, `re.sub` invokes our replacement function only for matches, so the callback receives a match object. Notice that I wrote `[0-9]` rather than the shorthand digit token. The shorthand can include many non-ASCII digits in Unicode text. If the incoming order system says four ASCII digits, we should encode that exact rule. Try changing `0042` to `0000` and predict what the group returns. It remains four text characters. Conversion with `int` is a separate, explicit operation.

  5. 02:47

    We are also using a raw string for the pattern so the backslashes we add next will reach the regex parser as intended. A replacement function receives one match at a time. For each one, we read the captured number, convert it with `int`, and build the new label. That is why `0007` becomes `7`, while `0042` becomes `42`. The computation is in normal Python code, where it is easier to inspect and test than a complicated replacement string. `re.sub` returns a new string, so printing the original source still shows its unchanged codes. Strings are immutable; the helper's return value is the transformed text.

  6. 03:28

    The two `\b` markers are word boundaries. The one before `ORD` prevents our code from starting in the middle of a larger word. The one after the four digits prevents it from accepting just the first four digits of a five-digit code. These boundaries are part of our input contract, not decorative regex syntax. The function should not erase unrelated text, punctuation, spaces, or lowercase identifiers. Ask whether we could instead use a backreference in the replacement string. We could preserve raw digits that way, but we need to strip leading zeros by integer conversion, so the callback is the direct and readable choice.

  7. 04:10

    Keep this function small. When input becomes a structured object with several fields, parsing JSON or CSV would be safer to maintain than extending one regex indefinitely. For this constrained text label, the pattern and callback are clear enough to explain line by line. This is the run that tells us whether the repair is actually narrow enough. `XORD-1234` is embedded in a larger word, so the first boundary rejects it. `ORD-12345` has too many digits, so the final boundary rejects the four-digit prefix. The lowercase version is left alone because the pattern is case sensitive,

  8. 04:50

    which is intentional for this order-code format. Only `ORD-9876` becomes `order:9876`. If a real system wanted lowercase codes too, we would write that requirement and update the pattern and tests together. We would not quietly turn on case- insensitive matching because a guess about the data format felt convenient. Try inserting a valid code twice. Both occurrences should be transformed in their original positions. Try an empty string: it should return an empty string without error. Try four Arabic-Indic numerals after `ORD-`: `[0-9]` should reject them.

  9. 05:33

    That may sound strict, but it is correct when the source format defines ASCII digits. The earlier broken pattern also replaced a five-digit value; the new output demonstrates that we fixed that precise defect rather than merely hiding it in a prettier sample. This way of testing is useful beyond regex. For every parser or validator, include a valid case, a nearby invalid case, and a boundary case that might slip through a partial match. These assertions verify the happy path, two near misses, and empty input. They are development checks, not the function's production input validation. We can now paste or load the same code into the browser console on the lesson

  10. 06:14

    page. The practical task starts with the deliberately broken replacement, so you can repair it yourself and select Check task. The lesson also has a short quiz about named groups, computed replacements, and boundaries. Completing both the task and quiz marks that lesson finished locally in your browser. Before moving on, look at the difference between the regex's job and the callback's job. The pattern identifies a complete eligible code. The callback decides how its captured number should be represented. The caller receives a new string and can choose whether to store it, print it, or pass it to another function.

  11. 06:55

    That separation is why the final code is easier to change when the output format changes. A final caution: the use of word boundaries assumes surrounding characters are ordinary word or non-word characters as Python defines them. If your identifier grammar permits unusual punctuation or adjoining Unicode letters, write tests for those cases and adjust the pattern to the actual specification. Do not declare a regex universally correct because it passed four classroom examples. Here it meets the stated four-digit order- code rule, and our output makes that claim visible.

Understand the concept

The first regex lesson found and validated matches. This lesson asks a different question: how do we keep a piece of each match while changing its representation? Parentheses create capture groups. A named group such as (?P<number>[0-9]{4}) gives the four digits a readable label. After re.search returns a match object, match.group('number') retrieves that exact substring. Always check for None before calling group when a match is optional.

re.sub(pattern, replacement, text) visits non-overlapping matches and returns a new string. A replacement can be fixed text, a backreference, or a function that receives each match. The function form is useful when a replacement requires Python logic, such as converting '0042' into the integer 42. It also keeps application code out of the regex pattern. Replacing text does not mutate the original string.

A pattern that matches ORD-[0-9]+ would silently accept an overlong code. Here a valid code means exactly four ASCII digits with word boundaries on both sides. The boundaries prevent finding ORD-0042 inside XORD-0042 or taking only the first four digits of ORD-00421. Python's [0-9] is deliberately narrower than the Unicode-aware \d token. State this rule before changing the data.

The checked task converts complete order codes into order:<number> while preserving all other characters. Test two valid matches, a repeated code, an empty input, an embedded fragment, lowercase text, and too many digits. If the input format grows more complex than a small pattern can explain, parse structured data instead of piling on regex punctuation. A regex is valuable when its boundaries are explicit and reviewable.

  • Named groups retain useful parts of a match
  • re.sub can call a Python replacement function
  • Word boundaries prevent partial replacement
  • Strings are returned rather than changed in place

See it step by step

Read the code, predict the output, then compare it with the result.

01. Read a named capture

PYTHON
import re
match = re.search(r'ORD-(?P<number>[0-9]{4})', 'Packed ORD-0042')
print(match.group('number'))
EXPECTED OUTPUT
0042

The named group holds only the digits. Check for None when a match may be absent.

02. Compute the replacement in a callback

PYTHON
import re
def replace(match):
    return f"order:{int(match.group('number'))}"
print(re.sub(r'\bORD-(?P<number>[0-9]{4})\b', replace, 'ORD-0007, ORD-0042'))
EXPECTED OUTPUT
order:7, order:42

The callback can convert the captured digits, unlike a fixed replacement string.

03. Keep invalid fragments unchanged

PYTHON
import re
pattern = r'\bORD-(?P<number>[0-9]{4})\b'
print(re.sub(pattern, 'MATCH', 'XORD-1234 ORD-12345 ORD-1234'))
EXPECTED OUTPUT
XORD-1234 ORD-12345 MATCH

The boundaries reject a code embedded in a larger word or extended by another digit.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Capture the part you need

A regex match is more than a yes-or-no result. The existing regex lesson finds complete order codes, but a transformation often needs one portion of each code. Put parentheses around that portion. Naming the group number makes later code easier to read than relying on group 1. A group returns text, so an order number with leading zeros remains '0042' until you deliberately convert it with int. That separation lets you test the pattern before adding conversion logic.

Run the example and inspect the exact output. Change the code to ORD-0123 and predict the capture before pressing Run. Then remove the code entirely: re.search returns None, not a match object, so calling group without a check would fail. Capturing does not change the source string. It only records the portion matched by the pattern. Use the named group in the later replacement step.

PYTHON
import re
match = re.search(r'ORD-(?P<number>[0-9]{4})', 'ORD-0042')
print(match.group('number'))
EXPECTED OUTPUT
0042
Follow the reasoning
  1. Match the complete marker and capture its four digits.
  2. Read the named group only after checking the optional match.
02 / 03

Derive a replacement from every match

A fixed re.sub replacement would turn every code into identical text and lose the captured number. A callback receives one match at a time and returns the string to substitute for that occurrence. Here the callback turns the four digits into an integer and prefixes it with order:. That conversion drops leading zeros by an explicit application rule. The original input remains unchanged because strings are immutable and re.sub returns a new string.

The example has two separate occurrences. Predict their converted values before running it. A callback is plain Python, so it can validate or format captured content, but avoid side effects that depend on an unpredictable order outside the input scan. Keep the function small and make the pattern decide what qualifies. If a complex record can be parsed as JSON or CSV, that structured format is clearer than stacking more regex captures.

PYTHON
import re
def replace(match):
    return f"order:{int(match.group('number'))}"
print(re.sub(r'\bORD-(?P<number>[0-9]{4})\b', replace, 'ORD-0007 | ORD-0042'))
EXPECTED OUTPUT
order:7 | order:42
Follow the reasoning
  1. Pass a function as the re.sub replacement.
  2. Convert the captured text only inside the callback.
03 / 03

Treat boundaries as part of the rule

Without an ending boundary, a pattern for four digits can accept the first four digits of a five-digit value. Without a starting boundary, it can match a code embedded inside another word. Neither is a complete ORD- plus four-digit token. Word boundaries prevent those two partial matches in our ASCII-style example. [0-9] also means ASCII digits deliberately, while the shorthand digit token can match digits from many scripts.

Run this boundary check and notice that only the last code is replaced. Try an empty string and repeated valid codes next. A boundary rule is not a substitute for a full parser when inputs can contain unusual punctuation or a new identifier format. Record the assumptions in the lesson so another developer knows which text should be left unchanged. The checked task tests these exact boundaries to make the behavior observable.

PYTHON
import re
pattern = r'\bORD-(?P<number>[0-9]{4})\b'
print(re.sub(pattern, 'MATCH', 'XORD-1234 ORD-12345 ORD-1234'))
EXPECTED OUTPUT
XORD-1234 ORD-12345 MATCH
Follow the reasoning
  1. Reject embedded prefixes and overlong numbers.
  2. Verify one complete code still transforms.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnMake normalize_orders(text) change every complete ORD- plus exactly four ASCII digits into order:<integer>, dropping leading zeros. Preserve other text unchanged. The prepared call must print 'Packed order:7 | order:42'. Reject partial or embedded codes by leaving them untouched.
Need a hint?

Use r'\bORD-(?P<number>[0-9]{4})\b' and pass a function to re.sub. In the callback, return f"order:{int(match.group('number'))}".

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.What does match.group('number') return for ORD-0042?

    02.When is a re.sub callback more useful than fixed replacement text?

    03.Why place a word boundary after four digits?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Calling group on a missing match

    COMMON MISTAKE
    re.search(r'ORD-[0-9]+', text).group()
    FIX
    match = re.search(r'ORD-[0-9]+', text)
    if match is not None:
        print(match.group())

    What happens: AttributeError occurs when search returns None.
    Check the optional match object before reading a group.

    Matching only a valid prefix

    COMMON MISTAKE
    re.sub(r'ORD-[0-9]{4}', 'order', 'ORD-12345')
    FIX
    re.sub(r'\bORD-[0-9]{4}\b', 'order', 'ORD-12345')

    What happens: The first four digits are changed and the fifth remains.
    Both boundaries make the pattern match a complete code.

    Browse this lesson’s problem fixes

    Sources and further reading