Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Regular Expressions  Module 8 – Flavours compared and regex in each language

Python regex techniques and the regex module

Scan text once with finditer, compute replacements with a function, split and keep separators, call re by keyword, and decide when to add the regex module.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Extract every record of a text in one scan with re.finditer, named groups and lastgroup
  • Rewrite matches with a function passed to re.sub, such as amounts turned into whole numbers of their smallest unit
  • Split text with re.split while keeping its separators, and predict where empty matches fall
  • Compile a pattern once, give count, maxsplit and flags by keyword, and validate whole values with fullmatch
  • Decide when the third-party regex module is worth adding, and when to stay on re or leave regex out

Before you start

What you’ll learn · Before you start

What you will learn

  • Extract every record of a text in one scan with re.finditer, named groups and lastgroup
  • Rewrite matches with a function passed to re.sub, such as amounts turned into whole numbers of their smallest unit
  • Split text with re.split while keeping its separators, and predict where empty matches fall
  • Compile a pattern once, give count, maxsplit and flags by keyword, and validate whole values with fullmatch
  • Decide when the third-party regex module is worth adding, and when to stay on re or leave regex out

Before you start

On this page

Three transaction messages, one of them without a reference number, go through two calls to re.findall: one for the amounts, one for the references. Zipped together, the two lists pair the 18,000.00 credit with the reference of the next message and drop the last debit, and nothing raises an error. The calls themselves are the subject of the Python track’s lesson on the re module; this one turns them into techniques that keep a text job correct: a scanner that reads the text once, a function that computes each replacement, splits that keep their separators, and calls written the way Python 3.13 and later expect.

The last section is a choice only your project can make: whether the third-party regex module, with its timeouts, Unicode properties and approximate matching, is worth adding, or whether the job belongs outside regular expressions altogether. Every example ran on Python 3.14.8, and each one runs again in your browser when you press Run.

One scan keeps the fields of each record together

The statement below holds three messages in a format made up for this lesson. The first part of the run is the bug from the opening: re.findall scans the whole text once for the amounts and a second time for the references, and zip pairs the two lists by position.

Two findall lists, a record scanner and a token scanner Python · scan/scan_messages.py
"""Three transaction messages in one text, in a format this lesson made up.

Two findall calls, one per field, give two lists that drift out of step as soon as one message
lacks a field. One record pattern scanned with finditer keeps every message's fields together,
and a token scanner reads fields that come in any order.
"""

import re

TEXT = """\
Rs 1,250.00 debited from A/c XX4821 on 05-03-31. Ref 7731.
Rs 18,000.00 credited to A/c XX4821 on 07-03-31.
Rs 99.50 debited from A/c XX4821 on 09-03-31. Ref 7790.
"""

# One findall per field: each call scans the whole text again, and zip pairs by position.
amounts = re.findall(r'Rs ([\d,]+\.\d\d)', TEXT)
refs = re.findall(r'Ref (\d+)', TEXT)
print('zip of two findall lists:')
for amount, ref in zip(amounts, refs):
    print(f'  {amount:>9}  ref {ref}')

# One pattern per message and one scan: a part that is missing comes back as None.
RECORD = re.compile(
    r'Rs (?P<amount>[\d,]+\.\d\d) (?P<kind>debited|credited)'
    r'.*? on (?P<date>\d\d-\d\d-\d\d)\.'
    r'(?: Ref (?P<ref>\d+)\.)?'
)
print('finditer, one dict per message:')
for m in RECORD.finditer(TEXT):
    print(' ', m.groupdict())

# A token scanner: one alternation of named groups, and lastgroup says which one matched.
TOKEN = re.compile(
    r'(?P<amount>Rs\.? ?[\d,]+(?:\.\d\d)?)'
    r'|(?P<date>\d\d-\d\d-\d\d)'
    r'|(?P<kind>debited|credited)'
)
line = 'On 11-03-31 A/c XX4821 was credited with Rs.2,000'
print('token scanner, fields in any order:')
for m in TOKEN.finditer(line):
    print(f'  {m.lastgroup:<6} {m[0]!r} at {m.start()}')

Output

zip of two findall lists:
   1,250.00  ref 7731
  18,000.00  ref 7790
finditer, one dict per message:
  {'amount': '1,250.00', 'kind': 'debited', 'date': '05-03-31', 'ref': '7731'}
  {'amount': '18,000.00', 'kind': 'credited', 'date': '07-03-31', 'ref': None}
  {'amount': '99.50', 'kind': 'debited', 'date': '09-03-31', 'ref': '7790'}
token scanner, fields in any order:
  date   '11-03-31' at 3
  kind   'credited' at 27
  amount 'Rs.2,000' at 41

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scan_messages.py

The second message has no reference, so the list of references is one item short and every pair after the first is wrong: the 18,000.00 credit got 7790, which belongs to the 99.50 debit, and zip dropped that debit because it stops at the shorter list. One call per field costs twice: the text is read again for every field, and the fields of one message stay together only while every message has all of them.

The cure is one pattern for a whole message, with a named group: A capturing group that carries a name as well as a number, so the match reads back by name instead of by slot: (?<year>\d{4}) in JavaScript, PCRE2, Go, PHP, Java and .NET; (?P<year>\d{4}) in Python, Go, PHP and PCRE2. More in “Named groups across regex flavours” for each field, and one scan with re.finditer. Each match it yields is one message, so its fields cannot drift apart, and groupdict() turns the match into a dict. The reference is an optional group, (?: Ref (?P<ref>\d+)\.)?, so the credit comes back with 'ref': None: an unmatched group: A capturing group that took no part in the match, because its branch of an alternation was not used or its repetition ran zero times. Engines report it as undefined, None, null or the empty string. More in “Capturing and non-capturing groups” stays inside its own record instead of moving every record after it. A loop like this is a scanner: Code that reads a text from left to right with one pattern and handles each match in turn, as a loop over re.finditer does. A tokenizer is a scanner whose alternatives name the kind of each token.. It reads the text from left to right with one pattern and handles each match as it comes, and since re.finditer returns an iterator, the matches of a long text are never collected in one list. pandas makes the same move for a whole column of texts: its Series.str.extract turns each named group into a column of a DataFrame (pandas API reference).

Feed the scanner a format it does not know

Press Edit, add the line Rs 500.00 debited from A/c XX4821. to TEXT, without a date, and run it again. The record scanner still prints three dicts: a message the pattern does not match is skipped without a word, so count the records you get against the messages you expected whenever the input can change.

When the fields of a message can come in any order, scan tokens instead of records. The last part of the run joins three named alternatives into one pattern. For each match, m.lastgroup names the alternative that matched, so the loop knows whether it read a date, a kind or an amount, and m.start() says where. The tokenizer in the re documentation is built the same way (Writing a Tokenizer).

Key idea

Match a whole record with one pattern and scan the text once with re.finditer: each match carries every field of one record, and a missing field is None in its own record instead of a shift in all the records after it.

Checkpoint

Check: Scanning records with finditer

  1. Question 1 of 2 Three messages are read with amounts = re.findall(...) and refs = re.findall(...), and only the second message has no reference. What does zip(amounts, refs) pair with the second amount?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The reference of the third message

    Two findall calls give two lists that know nothing of each other. The second message has no reference, so the references list is one item short, the second amount is paired with the third message's reference, and zip drops the last amount. One record pattern scanned with finditer keeps each message's fields in one match.

  2. Question 2 of 2 In the record scanner, the optional group (?: Ref (?P<ref>\d+)\.)? took no part in one match. What does m.groupdict() hold for ref in that match?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: None

    The credit message of the example came back as a dict with 'ref': None. Each match covers one message, so a missing field stays missing in that message alone.

A function can compute each replacement

re.sub(r'\d+', lambda m: str(int(m[0]) * 2), 'a3 b4') returns a6 b8: each number came back doubled, which no fixed replacement string: The new text given to a replace function. It has its own escapes, so a dollar sign or a backslash in it may name part of the match instead of standing for itself. More in “Writing patterns in code: strings and escaping” could do. When the replacement is a function, re.sub calls it once for every match, from left to right, hands it the match object and puts the string it returns where the match was (re.sub). Text between the matches is copied unchanged, and a backslash in the returned string stays a backslash, since only a replacement given as a string is read as a template.

A job that needs this: amounts written with grouping commas, turned into a whole number of the currency’s smallest unit, so that no floating-point rounding can creep in. The program below writes out as a loop what re.sub does with a function, then calls re.sub itself. Step through it and watch out grow by one piece of copied text and one return value of to_paise for each match:

Interactive · line by line What sub does with a function, written out as a loop Each pass of the loop takes one match: it copies the text before the match into out, then adds what to_paise returns. Watch out grow piece by piece.

Program · paise/sub_by_hand.py

import re

DIGITS = r'(\d+(?:,\d+)*)'
PAISE = r'(?:\.(\d\d))?'
AMOUNT = re.compile(DIGITS + PAISE)

def to_paise(m):
    whole = m[1].replace(',', '')
    frac = m[2] or '00'
    return str(int(whole + frac))

text = 'Rs 1,23,456.50 and Rs 99'
out = []
end = 0
for m in AMOUNT.finditer(text):
    found = m[0]
    out.append(text[end:m.start()])
    out.append(to_paise(m))
    end = m.end()
out.append(text[end:])
print(''.join(out))
print(AMOUNT.sub(to_paise, text))

Variables

After line 22: DIGITS is '(\\d+(?:,\\d+)*)'; PAISE is '(?:\\.(\\d\\d))?'; AMOUNT is re.compile('(\\d+(?:,\\d+)*)(?:\\.(\\d\\d))?'); to_paise is to_paise(m); text is 'Rs 1,23,456.50 and Rs 99'; out is ['Rs ', '12345650', ' and Rs ', '9900', '']; end is 24; m is <Match object>; found is '99'.DIGITS'(\\d+(?:,\\d+)*)'PAISE'(?:\\.(\\d\\d))?'AMOUNTre.compile('(\\d+(?:,\\d+)*)(?:\\.(\\d\\d))?')to_paiseto_paise(m)text'Rs 1,23,456.50 and Rs 99'out'Rs '0'12345650'1' and Rs '2'9900'3''4end24m<Match object>found'99'
  • changed in this step

Printed so far

Rs 12345650 and Rs 9900
Rs 12345650 and Rs 9900

Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.

The drawing shows the variables when the program ends, and the program's 23 steps are listed below.

Line run 22
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. import re loads re.
  3. Line 3. DIGITS = r'(\d+(?:,\d+)*)' sets DIGITS to '(\\d+(?:,\\d+)*)'.
  4. Line 4. PAISE = r'(?:\.(\d\d))?' sets PAISE to '(?:\\.(\\d\\d))?'.
  5. Line 5. AMOUNT = re.compile(DIGITS + PAISE) sets AMOUNT to re.compile('(\\d+(?:,\\d+)*)(?:\\.(\\d\\d))?').
  6. Line 7. def to_paise(m): makes the function to_paise. Its body runs only when it is called.
  7. Line 12. text = 'Rs 1,23,456.50 and Rs 99' sets text to 'Rs 1,23,456.50 and Rs 99'.
  8. Line 13. out = [] makes out a list of 0 items.
  9. Line 14. end = 0 sets end to 0.
  10. Line 15. for m in AMOUNT.finditer(text): starts: m is <Match object>.
  11. Line 16. found = m[0] sets found to '1,23,456.50'.
  12. Line 17. out.append(text[end:m.start()]) adds 'Rs ' to the end of out. The text before the first match, the three characters Rs and a space, goes into out unchanged. Text the pattern did not match is copied as it is.
  13. Line 18. out.append(to_paise(m)) adds '12345650' to the end of out. Key point to_paise received the match of 1,23,456.50 and returned the string 12345650. That return value, and nothing else, takes the place of the match.
  14. Line 19. end = m.end() sets end to 14 (it was 0). end moves to 14, the position just after the match, so the next copy starts there.
  15. Line 15. for m in AMOUNT.finditer(text): goes round again: m is <Match object>.
  16. Line 16. found = m[0] sets found to '99' (it was '1,23,456.50').
  17. Line 17. out.append(text[end:m.start()]) adds ' and Rs ' to the end of out.
  18. Line 18. out.append(to_paise(m)) adds '9900' to the end of out. The amount 99 has no paise part, so its second group is None and to_paise adds two zeros: 9900 paise.
  19. Line 19. end = m.end() sets end to 24 (it was 14).
  20. Line 15. for m in AMOUNT.finditer(text): has no item left, so the loop ends.
  21. Line 20. out.append(text[end:]) adds '' to the end of out.
  22. Line 21. print(''.join(out)) prints Rs 12345650 and Rs 9900.
  23. Line 22. print(AMOUNT.sub(to_paise, text)) prints Rs 12345650 and Rs 9900. That was the last line, so the program ends. Both printed lines are the same. AMOUNT.sub(to_paise, text) does what this loop does: one call of to_paise per match, from left to right, with the text between matches kept.

Both lines print Rs 12345650 and Rs 9900. For each match, to_paise read the two groups, the whole amount with its commas and the optional two digits after the point, and joined them as text before a single int(). Press Edit, change text to 'Rs 1,23,456.50 and Rs 99 and Rs 7.05' and run it: the third amount comes out as 705.

Lakh grouping and paise

Indian amounts group the digits in twos above the thousands, so the first comma of 1,23,456.50 marks the lakhs: one lakh and 23,456 rupees, plus 50 paise. A crore is a hundred lakh, and a rupee is 100 paise, so an amount kept as a whole number of paise stays exact. The pattern \d+(?:,\d+)* accepts this grouping and the international one, 123,456.50, alike.

The function has one rule to keep: it must return a str. The next run breaks that rule on purpose, and its table shows why the digits travel as text and not as a float:

What a replacement function must return, and a float on the way Python · callback/callback_rules.py
"""re.sub with a function: it is called with each match and returns the text that replaces it."""

import re

# The function's return value goes where the match was.
print(re.sub(r'\d+', lambda m: str(int(m[0]) * 2), 'a3 b4'))

# An amount through float() can lose a paisa; the same digits joined as text cannot.
for amount in ['19.99', '2.30', '1,23,456.29']:
    whole, _, frac = amount.replace(',', '').partition('.')
    via_float = int(float(f'{whole}.{frac}') * 100)
    via_digits = int(whole + frac.ljust(2, '0'))
    print(f'{amount:>12}  float {via_float:>9}  digits {via_digits:>9}')

# The function must return a str: an int stops the whole call.
try:
    re.sub(r'\d+', lambda m: int(m[0]) * 2, 'a3 b4')
except TypeError as e:
    print('TypeError:', e)

Output

a6 b8
       19.99  float      1998  digits      1999
        2.30  float       229  digits       230
 1,23,456.29  float  12345629  digits  12345629
TypeError: sequence item 1: expected str instance, int found

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 callback_rules.py

An int stops the whole call with TypeError: sequence item 1: expected str instance, int found. The message comes from the join at the end of re.sub: item 0 is the a copied from before the first match, and item 1 is the first replacement. The table is the money trap: int(float('19.99') * 100) gives 1998, one short, because most decimal fractions have no exact binary form (Python tutorial, floating-point arithmetic), while joining the digits gives 1999. The float happens to be right for 1,23,456.29, which is how such a bug survives a test with a few round amounts.

Converting money through a float

Wrong: int(float(amount.replace(',', '')) * 100), which turns 19.99 into 1998 and 2.30 into 229. Right: capture the whole part and the fraction as two groups and join their digits before one int(), as to_paise does.

Everything the function needs comes with the match: m[0] is the whole match, m[1] or m['name'] a group, and m.start() its position, so a replacement can depend on any of them.

Key idea

A function given to re.sub runs once per match, left to right, and the str it returns takes the match’s place, so each replacement can be computed from what was matched.

Checkpoint

Check: A function as the replacement

  1. Question 1 of 2 How many times does re.sub(r'\d+', f, 'a3 b45 c6') call the function f?

    Type a number.

    A hint

    The function runs once per match. How many runs of digits are in the text?

    Show the answer: Question 1 of 2

    Answer: 3 calls

    re.sub calls a replacement function once for every match, from left to right. The pattern matches 3, 45 and 6, so f runs three times, and each call gets one match object.

  2. Question 2 of 2 A replacement function returns int(m[0]) * 2, an int. What does re.sub(r'\d+', that_function, 'a3 b4') do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It raises TypeError, because the replacement must be a str

    The function must return a str. The run of the lesson shows the error: TypeError: sequence item 1: expected str instance, int found, where item 1 is the first replacement after the copied a.

Split keeps what its groups capture, and empty matches count

re.split(r'(\d)', 'a1b2c') returns ['a', '1', 'b', '2', 'c']: the digits it split on are in the list too, each between the two pieces it separated. Python adds the text of every group: A part of a pattern wrapped in parentheses whose matched text the engine remembers separately, for reuse or extraction. More in “What a regular expression is and where it runs” of the separator to the result (re.split). With one group around the whole separator, ''.join() of the list gives the original text back, as the first line shows, which makes split a lossless way to cut a text apart, change some of its pieces and put it back together.

Split with groups, maxsplit by keyword, and empty matches Python · split/split_groups.py
"""re.split keeps what a capturing group matched, and re.sub replaces empty matches too."""

import re

# One group around the separator: the separators stay in the list, in their places.
parts = re.split(r'(\d)', 'a1b2c')
print(parts, '->', ''.join(parts))
print(re.split(r'\s*([+*/-])\s*', '12 + 3*4 - 5'))

# Two groups: every split adds one item per group, None for a group that took no part.
print(re.split(r'(-)|(\+)', 'a-b+c'))

# maxsplit, given by keyword: the rest of the text stays in the last item.
print(re.split(r'(\d)', 'a1b2c', maxsplit=1))

# a* also matches the empty string: where it matches, and what sub and split do there.
print([m.span() for m in re.finditer(r'a*', 'baac')])
print(re.sub(r'a*', '-', 'baac'))
print(re.split(r'a*', 'baac'))

Output

['a', '1', 'b', '2', 'c'] -> a1b2c
['12', '+', '3', '*', '4', '-', '5']
['a', '-', None, 'b', None, '+', 'c']
['a', '1', 'b2c']
[(0, 0), (1, 3), (3, 3), (4, 4)]
-b--c-
['', 'b', '', 'c', '']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 split_groups.py

The second line keeps the operators of an arithmetic expression and drops the spaces around them, because the spaces sit outside the group. The third shows the price of two groups: every split adds one item per group, and the group that took no part adds None, so (-)|(\+) leaves two None items in the list and ''.join() of it would fail. The fourth line stops after one split, maxsplit=1, and leaves the rest of the text in the last item.

One group instead of two

Press Edit and write the third pattern as ([-+]). The list comes back without None, and ''.join() of it gives a-b+c again.

The last three lines are about the empty match: A match of length zero: the pattern succeeds at a position without taking any character, as x* does where no x stands. Global searches, splits and replacements report such matches, each engine by its own rules. More in “Repetition with *, +, ? and {m,n}”. a* matches the empty string wherever no a stands, and the spans show four matches in baac: at 0, from 1 to 3, at 3 and at 4. An empty match may come right after a non-empty one (re.sub), which is where the span (3, 3) comes from:

Position in baac What a* matches there What re.sub writes
0, before b the empty string a -, then the b unchanged
1 to 3 aa a -
3, before c the empty string, right after aa a -, then the c unchanged
4, the end the empty string a -

So re.sub(r'a*', '-', 'baac') is -b--c-, and re.split with the same pattern gives five items, three of them empty. JavaScript’s replace gives the same -b--c-, while Go’s regexp ignores an empty match that touches the match before it (package regexp) and gives -b-c-. The porting lesson covers moving such a pattern between engines.

Empty matches since Python 3.7

Python 3.7 changed the rules for empty matches: in re.sub an empty match may follow a non-empty one, re.findall and re.finditer may start a non-empty match right after an empty one, and re.split can split at an empty match (re, the notes of each function). Code that must also run on an older interpreter can get other results from the same pattern.

Checkpoint

Check: Split with groups, and empty matches

  1. Question 1 of 2 What does re.split(r'(,)|(;)', 'a,b;c') return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ['a', ',', None, 'b', None, ';', 'c']

    re.split adds the text of every capturing group of the separator to the list. The first split matched the comma group and the second the semicolon group, so each adds the separator and a None for the other group.

  2. Question 2 of 2 What does re.sub(r'x*', '.', 'axb') return in Python 3.14?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: .a..b.

    The matches of x* in axb are at 0 (empty), from 1 to 2 (x), at 2 (empty, right after the x) and at 3 (empty, the end). Each becomes a dot and the letters between are copied, which gives .a..b., the same rule that turns baac into -b--c- in the lesson.

Compile once, and give count, maxsplit and flags by name

re.sub('x', '-', 'XxXx', re.I) returns X-X-, not ----. The fourth parameter of re.sub is count, and re.I is the number 2, so the call replaced the first two lowercase x and did not ignore case at all. Python 3.13 deprecated passing count, maxsplit or flags to re.sub, re.subn and re.split by position (What is new in Python 3.13), so the call now warns as well. The run below collects its warnings with warnings.catch_warnings(record=True) and prints them, so it ends normally:

Arguments by position and by keyword, then match and fullmatch Python · calls/call_style.py
"""Python 3.13 and later warn when count, maxsplit or flags reach re.sub, re.subn or re.split
by position. The warnings are caught and printed here, so the run still ends normally."""

import re
import warnings


def call(label, fn):
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter('always')
        result = fn()
    print(f'{label} -> {result!r}')
    for w in caught:
        print(f'    {w.category.__name__}: {w.message}')


call("re.sub('x', '-', 'XxXx', re.I)", lambda: re.sub('x', '-', 'XxXx', re.I))
call("re.sub('x', '-', 'XxXx', flags=re.I)", lambda: re.sub('x', '-', 'XxXx', flags=re.I))
call("re.split(',', 'a,b,c', 1)", lambda: re.split(',', 'a,b,c', 1))
call("re.split(',', 'a,b,c', maxsplit=1)", lambda: re.split(',', 'a,b,c', maxsplit=1))
call("re.compile('x', re.I).sub('-', 'XxXx', 1)", lambda: re.compile('x', re.I).sub('-', 'XxXx', 1))
print('re.I is the number', int(re.I))

# Compiled once, used for every value: match anchors only the start, fullmatch the whole value.
CODE = re.compile(r'\d{6}')
CODE_DOLLAR = re.compile(r'\d{6}$')
for value in ['400001', '4000012', '400001\n']:
    print(f'{value!r:<11} match {bool(CODE.match(value))!s:<5}  '
          f'match with $ {bool(CODE_DOLLAR.match(value))!s:<5}  '
          f'fullmatch {bool(CODE.fullmatch(value))}')

Output

re.sub('x', '-', 'XxXx', re.I) -> 'X-X-'
    DeprecationWarning: 'count' is passed as positional argument
re.sub('x', '-', 'XxXx', flags=re.I) -> '----'
re.split(',', 'a,b,c', 1) -> ['a', 'b,c']
    DeprecationWarning: 'maxsplit' is passed as positional argument
re.split(',', 'a,b,c', maxsplit=1) -> ['a', 'b,c']
re.compile('x', re.I).sub('-', 'XxXx', 1) -> '-xXx'
re.I is the number 2
'400001'    match True   match with $ True   fullmatch True
'4000012'   match True   match with $ False  fullmatch False
'400001\n'  match True   match with $ True   fullmatch False

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 call_style.py

Each warning names the parameter that came by position, as in 'count' is passed as positional argument. Written with flags=re.I and maxsplit=1, the same calls run silently and say what they mean. A compiled pattern: The object that re.compile returns, a re.Pattern: the pattern parsed once with its flags fixed, so its search, finditer, sub and fullmatch methods take the text but no flags argument. has no such trap: its flags were fixed by re.compile, its sub method has no flags parameter, and the run shows it taking a count by position without a warning.

Compile a pattern once, at the top of the module, as soon as more than one place uses it. The module-level functions also keep their recently compiled patterns in a cache (re.compile), so compiling the same pattern again costs little; what compiling buys is a name that says what the pattern is for, flags written in one place, and methods that take a start and an end position as well.

For validation, the call matters as much as the pattern. The last three lines run a six-digit check three ways. match anchors only at the start, so it accepts 4000012. A $ at the end refuses that value but still accepts 400001 with a line feed after it, the trailing-newline trap: A validation bug: a pattern such as ^abc$ accepts abc followed by a line feed in engines whose $ also holds before a final newline, so the checked value still carries the extra character. More in “Anchors: start, end and multiline mode”, because $ also matches before a final newline. fullmatch asks for a full match: A match that must cover the whole text, from its first character to its last. Python's re.fullmatch asks for it; match and search do not. More in “How an engine finds a match, step by step” and refuses both.

A second name for match in Python 3.15

Python 3.15 gives re.match() a more explicit name, re.prefixmatch(), and marks the old one as soft deprecated, with no plan to remove it (What is new in Python 3.15). The 3.14.8 interpreter these examples ran on has no prefixmatch; in both versions, a check of a whole value calls fullmatch.

Checkpoint

Check: Compiling, keyword arguments and fullmatch

  1. Question 1 of 2 What does re.sub('a', '-', 'AaAa', re.I) return in Python 3.14?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A-A-, with a DeprecationWarning that count was passed by position

    re.sub(pattern, repl, string, count=0, flags=0) takes count fourth. A flag given there becomes a count of 2, so the call is case-sensitive and stops after two replacements. Python 3.13 and later warn: 'count' is passed as positional argument. Write flags=re.I.

  2. Question 2 of 2 A six-digit field arrives as '400001\n', with a line feed at the end. Which call refuses it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: re.fullmatch(r'\d{6}', value)

    In the lesson's run, match accepted the value with the line feed, match with a dollar sign accepted it too, and only fullmatch refused it. Validation code calls fullmatch.

What re cannot do, and when the regex module earns its place

Ask re itself what it lacks. The first three lines below print every parameter of re.search, re.sub and re.finditer: a pattern, a string, the replacement and count of sub, flags, and nothing that limits time. The next three compile escapes that other engines know, and re refuses each one with PatternError: bad escape:

What re refuses, and the newer syntax it accepts Python · limits/limits.py
"""What Python 3.14's re module accepts and refuses, asked of re itself."""

import inspect
import re

# No timeout: these are every parameter the matching functions take.
for name in ('search', 'sub', 'finditer'):
    print(f're.{name}{inspect.signature(getattr(re, name))}')

# Escapes of other engines that re refuses.
for pattern in (r'\p{L}', r'\X', r'\Ga'):
    try:
        re.compile(pattern)
    except re.PatternError as e:
        print(f'{pattern:<6} {type(e).__name__}: {e}')

# Syntax that re accepts: atomic groups and possessive quantifiers (3.11),
# a missing lower bound, and \z (3.14).
print('(?>a+)b on aab:', bool(re.fullmatch(r'(?>a+)b', 'aab')))
print('a++a on aaa:', bool(re.fullmatch(r'a++a', 'aaa')))
print('a{,2} on aaa:', re.findall(r'a{,2}', 'aaa'))
print(r'\d$ on "42\n":', bool(re.search(r'\d$', '42\n')))
print(r'\d\z on "42\n":', bool(re.search(r'\d\z', '42\n')))

Output

re.search(pattern, string, flags=0)
re.sub(pattern, repl, string, count=0, flags=0)
re.finditer(pattern, string, flags=0)
\p{L}  PatternError: bad escape \p at position 0
\X     PatternError: bad escape \X at position 0
\Ga    PatternError: bad escape \G at position 0
(?>a+)b on aab: True
a++a on aaa: False
a{,2} on aaa: ['aa', 'a', '']
\d$ on "42\n": True
\d\z on "42\n": False

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 limits.py

With no match timeout: A limit on how long one matching call may run, after which the engine gives up with an error instead of an answer. Python's re has none; the third-party regex module takes timeout= in seconds., a slow match runs until it ends, and nothing outside the pattern stops it. For a pattern you write yourself, the fix is the pattern, which the track’s module on performance, ReDoS and security takes up. \p{L} and the other Unicode property escape: A pattern escape such as \p{L} (any letter) or \p{Nd} (any decimal digit) that matches characters by a Unicode property instead of a list; \P matches the rest. JavaScript needs the u or v flag for it, and Python's re module lacks it. More in “Shorthand classes for digits, words and spaces” are missing too; the shorthand classes lesson builds such a class from the unicodedata module instead. There is no \X for one user-perceived character, and \G is refused as well.

The last five lines show the other side: syntax that re accepts, three pieces of it added in recent versions. An atomic group: A group written (?>...) that keeps the first match its own pattern finds: a failure later in the pattern cannot backtrack into it to make it try another way. x++ means the same as (?>x+). More in “Possessive quantifiers and atomic groups”, (?>a+), and a possessive quantifier: A quantifier with an extra plus sign, such as x*+, x++, x?+ or x{m,n}+, that matches as much as a greedy one and then keeps it: it saves no shorter run, so a failure later in the pattern never makes it give characters back. More in “Possessive quantifiers and atomic groups”, a++, are accepted, so a++a cannot match aaa: a++ keeps all three letters and gives none back. A repetition bound: The lowest or the highest count a quantifier allows, such as 3 and 5 in {3,5}. A missing highest bound, as in {3,}, means no limit, though each engine caps how large a written count may be. More in “Repetition with *, +, ? and {m,n}” without its lower number, a{,2}, means a{0,2}, and \z matches only at the very end, where $ also takes a final line feed (re, regular expression syntax).

Newer syntax needs a newer Python

Atomic groups and possessive quantifiers arrived in Python 3.11, and \z in Python 3.14 (re, regular expression syntax). On an older interpreter a pattern that uses them does not compile, so note the minimum version next to such a pattern.

The third-party regex module is the usual answer when these limits bite. Its README calls it backwards-compatible with re, and it adds each thing re lacks: a timeout= argument, in seconds, on its matching functions, which raises TimeoutError when the time runs out; \p{…} for general categories, scripts and blocks; \X for one What a reader sees as one character, which may be several code points: a Hindi letter with its vowel sign is one grapheme made of two code points., following Unicode’s rules for text boundaries; recursive patterns such as (?R); and fuzzy match: A match that allows a set number of wrong characters, each one inserted, deleted or substituted. The third-party regex module for Python writes the limit after a group, as {e<=1}; re only matches exactly., where {e<=1} after a group lets the text differ by one inserted, deleted or substituted character (mrab-regex README). It is not part of Python. A project that wants it adds it from PyPI as a dependency, pins a version and keeps it updated, and the PyPI page lists Python 3.10 or later; no example on this page imports it.

Whether that dependency pays for itself depends on your project more than on the module. Answer the four questions for a job you have in mind and see which ways stay open:

Interactive · answer and compare Stay on re, add the regex module, or leave regex out? Answer four questions about your own project and see which of the three ways is ruled out, and which fits only with a caveat.
Can a pattern itself come from a user, such as a saved search or a filter rule?
Does the job need Unicode property classes or whole graphemes, such as Hindi letters with their vowel signs?
Can the project add a third-party package and keep it updated?
Must the job accept near-miss spellings, such as a name typed with one wrong letter?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to handle a text job that re finds hard, against four needs a project may have
Way to go A pattern from a user Unicode properties or graphemes Nothing new to install Near-miss spellings
Stay on re Means: the standard library module every example of this lesson runs Poor no timeout: search, sub and finditer take only the pattern, the text and flags Mixed \p and \X are bad escapes; a class built from unicodedata covers categories Good part of the standard library, so it runs wherever Python runs Poor exact matches only: no syntax allows a wrong character
Add the regex module Means: a third-party package from PyPI, pinned in the project Good its matching calls take timeout= in seconds and raise TimeoutError when it runs out Good \p{...} for categories, scripts and blocks, and \X for one grapheme Poor one more package to pin, update and ship; it needs Python 3.10 or later Good {e<=1} after a group allows one inserted, deleted or substituted character
Leave regex out Means: string methods, unicodedata, difflib or a real parser Mixed right when users mean plain text: str.find takes it literally, with no pattern Mixed unicodedata.category tests characters one at a time, without \p syntax Good string methods, unicodedata and difflib come with Python Mixed difflib.get_close_matches ranks whole words from a list, not spans inside a text
How the answers decide
  • “Can a pattern itself come from a user, such as a saved search or a filter rule?” Yes, users write patterns: rules out Stay on re. re has no timeout, so nothing outside the pattern can stop a slow match. The regex module takes timeout= on every matching call.
  • “Can a pattern itself come from a user, such as a saved search or a filter rule?” Yes, users write patterns: adds a caveat to Leave regex out. This fits only when users mean literal text: str.find, or re.escape before compiling, treats their input as plain characters.
  • “Does the job need Unicode property classes or whole graphemes, such as Hindi letters with their vowel signs?” Yes: adds a caveat to Stay on re. re refuses \p and \X. A class built from unicodedata, as the shorthand-classes lesson builds it, covers general categories but not graphemes.
  • “Can the project add a third-party package and keep it updated?” No, standard library only: rules out Add the regex module. The regex module is a package from PyPI; a project that can add nothing outside the standard library cannot use it.
  • “Must the job accept near-miss spellings, such as a name typed with one wrong letter?” Yes: rules out Stay on re. A pattern in re matches the text exactly; it has no way to count wrong characters.
  • “Must the job accept near-miss spellings, such as a name typed with one wrong letter?” Yes: adds a caveat to Leave regex out. difflib compares whole strings with a list of known words; it cannot find a near-miss spelling inside a longer message.

In short: stay on re for patterns you write, with classes built from unicodedata where categories are needed; add regex when users write the patterns, when graphemes, scripts or misspellings matter, and the project can take a dependency; and leave regular expressions out when users mean plain text or the input is a format with a parser of its own.

Checkpoint

Check: The limits of re and the regex module

  1. Question 1 of 2 How do you make re.search give up after two seconds, using re alone?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: You cannot: the matching functions of re take no timeout

    re has no match timeout. For your own patterns the fix is the pattern; when a time limit is a requirement, the regex module's timeout= in seconds is one way, as the lesson's last section describes.

  2. Question 2 of 2 A project may use only the standard library, and it must cut Hindi text into words with their vowel signs kept. Which way fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Stay on re, with a class of letters and marks built from unicodedata

    With no dependency allowed, the regex module is ruled out, and re has no \p. A class built from unicodedata covers the general categories of letters and marks, which is all this job needs; it cannot match graphemes, which this job does not ask for.

Practise: parse a statement of transactions

The exercise puts the lesson into one function. parse_transactions(text) reads messages in five formats made up for it, finds each message’s date, amount and direction, and returns one dict per transaction, with the date in ISO form and the amount as a whole number of paise. The starter knows one format and converts through a float, and the sample tests show where both choices break.

Exercise · Medium · Python

Parse a statement of transaction messages

Write parse_transactions(text) in transactions.py. The text holds messages about one bank account, one message per line, in a format this exercise defines (it copies no real bank's messages). The function returns one dict per transaction, in the order of the lines:

{"date": "2031-03-05", "amount": 125000, "type": "debit"}
  • amount: the number right after Rs, Rs. or INR, with or without a space after the marker. It may have grouping commas, Indian (2,45,000) or international (245,000), and one or two digits after a decimal point. Return it as a whole number of paise, the hundredths of the amount: 1,250.00 is 125000, 99.5 is 9950, 300 is 30000. Keep it exact: no float on the way.
  • type: "debit" when the line says debited or spent, "credit" when it says credited or received, in any case.
  • date: in ISO form, YYYY-MM-DD. A message writes it in one of five ways: 05-03-31 (day, month, two-digit year), 07/03/2031, 09-Mar-31, 13 Mar 2031 (a three-letter month in any case) or 2031-03-12. A two-digit year means a year from 2000 to 2099.

A line without an amount, a type or a date is not a transaction and is skipped, and so is an empty text. Other numbers in a line, such as the account number XX4821 or a reference such as Ref 7731, are never the amount.

These lines show all five formats:

Rs 1,250.00 debited from A/c XX4821 on 05-03-31. Ref 7731.
INR 18,000 credited to A/c XX4821 on 07/03/2031.
A/c XX4821 debited by Rs.99.50 on 09-Mar-31.
Received Rs 2,45,000.75 in A/c XX4821 on 2031-03-12.
Spent Rs 19.99 at a store on 13 Mar 2031 from A/c XX4821.

The starter reads the first format only, and it turns the amount into paise through float(): the sample tests show both problems.

Starter code · transactions.py

"""parse_transactions(text): one dict per transaction message of the text, in order.

The prompt describes the exercise's message format: five ways to write the date, three currency markers, four words
for the type. This starter reads only the first format, and it converts the amount through float().
"""

import re

# Starter: the first format only, 'Rs 1,250.00 debited from A/c XX4821 on 05-03-31. Ref 7731.'
FIRST_FORMAT = re.compile(
    r'Rs (?P<amount>[\d,]+\.\d\d) (?P<kind>debited|credited)'
    r'.*? on (?P<day>\d\d)-(?P<month>\d\d)-(?P<year>\d\d)'
)


def parse_transactions(text):
    """One dict per transaction line: {'date': 'YYYY-MM-DD', 'amount': paise as an int, 'type': 'debit' or 'credit'}."""
    found = []
    for m in FIRST_FORMAT.finditer(text):
        found.append({
            'date': f"20{m['year']}-{m['month']}-{m['day']}",
            'amount': int(float(m['amount'].replace(',', '')) * 100),
            'type': 'debit' if m['kind'] == 'debited' else 'credit',
        })
    return found
The sample tests · test_transactions.py
from transactions import parse_transactions


def test_first_format():
    """a debit with grouping commas and a two-digit year"""
    text = 'Rs 1,250.00 debited from A/c XX4821 on 05-03-31. Ref 7731.'
    assert parse_transactions(text) == [{'date': '2031-03-05', 'amount': 125000, 'type': 'debit'}]


def test_credit_without_decimals():
    """INR, no decimal point, a slash date with a four-digit year"""
    text = 'INR 18,000 credited to A/c XX4821 on 07/03/2031.'
    assert parse_transactions(text) == [{'date': '2031-03-07', 'amount': 1800000, 'type': 'credit'}]


def test_month_names():
    """Rs. without a space, and three-letter months in two date forms"""
    text = 'A/c XX4821 debited by Rs.99.50 on 09-Mar-31.\nSpent Rs 450 at a store on 13 MAR 2031 from A/c XX4821.'
    assert parse_transactions(text) == [
        {'date': '2031-03-09', 'amount': 9950, 'type': 'debit'},
        {'date': '2031-03-13', 'amount': 45000, 'type': 'debit'},
    ]


def test_received_and_iso_date():
    """received counts as a credit, Indian grouping, an ISO date"""
    text = 'Received Rs 2,45,000.75 in A/c XX4821 on 2031-03-12.'
    assert parse_transactions(text) == [{'date': '2031-03-12', 'amount': 24500075, 'type': 'credit'}]


def test_amounts_stay_exact():
    """no float on the way: 19.99 is 1999 paise, and one decimal digit means tens of paise"""
    text = 'Rs 19.99 debited from A/c XX4821 on 13-03-31.\nRs 2.5 credited to A/c XX4821 on 14-03-31.'
    assert [t['amount'] for t in parse_transactions(text)] == [1999, 250]


def test_other_numbers_are_not_amounts():
    """the account number and the reference are never the amount"""
    text = 'A/c XX4821 debited by INR 300 on 15/03/2031. Ref 4500.'
    assert parse_transactions(text) == [{'date': '2031-03-15', 'amount': 30000, 'type': 'debit'}]


def test_lines_that_are_not_transactions():
    """a balance line and an empty line are skipped, and the order of the rest is kept"""
    text = (
        'Rs 99.50 debited from A/c XX4821 on 09-03-31. Ref 7790.\n'
        'Your balance in A/c XX4821 is Rs 5,000.00.\n'
        '\n'
        'INR 1,000 credited to A/c XX4821 on 10/03/2031.\n'
    )
    assert parse_transactions(text) == [
        {'date': '2031-03-09', 'amount': 9950, 'type': 'debit'},
        {'date': '2031-03-10', 'amount': 100000, 'type': 'credit'},
    ]


def test_empty_text():
    """an empty text has no transactions"""
    assert parse_transactions('') == []
A hint

Treat each line as one record and look for its three fields inside that line only, so a field can never come from another message. One compiled pattern per field is easier to get right than one pattern for every order: the amount starts at its currency marker, the type is one of four words, and the five date forms fit one alternation with named groups. For the amount, capture the whole part and the decimals as two groups and join their digits before a single int(), as the lesson's to_paise does.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Number Extractor Paste a few of the exercise's messages to see every amount in them, Indian digit grouping included, and compare that list with what your function returns.

Five ideas, one round of practice. It opens on the idea your answers show you know least, and each choice tells you why it is right or wrong as soon as you choose it.

Practice that adapts to you

Practise this lesson

16 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 16 What does re.finditer(pattern, text) return?

    Choose one answer.

    Show the answer: Question 1 of 16

    Answer: An iterator of match objects, in the order the matches occur

    re.finditer yields one match object per non-overlapping match, scanning the text from left to right. Because it is an iterator, a loop can handle each match as it comes, and each match object still has groupdict(), lastgroup and start().

  2. Question 2 of 16 What does the last line print?

    Read the code, then choose one answer.

    import re
    TOKEN = re.compile(r'(?P<num>\d+)|(?P<word>[a-z]+)|(?P<op>[+*])')
    print([m.lastgroup for m in TOKEN.finditer('x+12*y')])
    Show the answer: Question 2 of 16

    Answer: ['word', 'op', 'num', 'op', 'word']

    The scanner reads x+12*y from left to right. At each position one alternative matches, and m.lastgroup names it: word for x, op for +, num for 12, op for *, word for y.

  3. Question 3 of 16 A record scanner runs over a day of messages, and one sender changes its message format. What happens to those messages, and how would you notice?

    Choose one answer.

    Show the answer: Question 3 of 16

    Answer: finditer skips them without an error, so compare the number of records with the number of messages

    The try callout of the lesson shows it: a line without a date added nothing to the output. A scanner is silent about what it skipped, so a parser that may meet new formats should check that it read as many records as the input holds.

  4. Question 4 of 16 What does re.sub pass to a replacement function?

    Choose one answer.

    Show the answer: Question 4 of 16

    Answer: One match object, once for every match

    re.sub calls the function for every non-overlapping match and passes one match object each time. What the function returns, a str, takes the place of that match.

  5. Question 5 of 16 What does this print?

    Read the code, then choose one answer.

    import re
    print(re.sub(r'(\w)(\d)', lambda m: m[2] + m[1], 'a1 b2'))
    Show the answer: Question 5 of 16

    Answer: 1a 2b

    The pattern matches a1 and b2. For each, the function joins group 2 and group 1, giving 1a and 2b, and the space between the matches is copied unchanged.

  6. Question 6 of 16 Why does to_paise join the digits as text and call int() once, instead of computing int(float(amount) * 100)?

    Choose one answer.

    Show the answer: Question 6 of 16

    Answer: Most decimal fractions have no exact binary float, so the product can fall just short and int() cuts it down

    19.99 has no exact binary form, so 19.99 * 100 is a little under 1999 and int() gives 1998. Joining the whole part and the two-digit fraction as text and calling int() once gives 1999, and every amount stays exact.

  7. Question 7 of 16 What does re.split(r'(;)', 'a;b') return?

    Choose one answer.

    Show the answer: Question 7 of 16

    Answer: ['a', ';', 'b']

    Capturing groups in the separator add their text to the result, so a split with one group around the whole separator can be joined back into the original text.

  8. Question 8 of 16 What does this print?

    Read the code, then choose one answer.

    import re
    print(re.split(r'(-)', 'a-b-c-d', maxsplit=2))
    Show the answer: Question 8 of 16

    Answer: ['a', '-', 'b', '-', 'c-d']

    maxsplit counts splits, not items. After two splits the remainder, c-d, is returned as the last item, and the group adds the two separators it matched.

  9. Question 9 of 16 What does re.findall(r'a*', 'baac') return in Python 3.14?

    Choose one answer.

    Show the answer: Question 9 of 16

    Answer: ['', 'aa', '', '']

    The spans printed by the split example were (0, 0), (1, 3), (3, 3) and (4, 4). findall lists the matched text of each, so it returns an empty string, aa, and two more empty strings.

  10. Question 10 of 16 re.split(r'(,)|(;)', line) leaves None items that break ''.join(). Which pattern keeps every separator and leaves no None?

    Choose one answer.

    Show the answer: Question 10 of 16

    Answer: ([,;])

    Each capturing group adds one item per split, and a group that did not take part adds None. With a single group that matches every separator, the list alternates pieces and separators, and join gives the line back.

  11. Question 11 of 16 Which arguments of re.sub does Python 3.13 and later want by keyword?

    Choose every answer that is right.

    Show the answer: Question 11 of 16

    Answer:

    • flags
    • count

    Python 3.13 deprecated passing count, maxsplit or flags to the module-level re.sub, re.subn and re.split by position, and a future version makes them keyword-only. Writing count=1 and flags=re.I keeps the code working and makes a flag-for-count mix-up impossible.

  12. Question 12 of 16 What happens when this runs on Python 3.14?

    Read the code, then choose one answer.

    import re
    PATTERN = re.compile('x', re.I)
    print(PATTERN.sub('-', 'XxXx', 1))
    Show the answer: Question 12 of 16

    Answer: It prints -xXx and warns about nothing

    A compiled pattern carries its flags, so its sub method has no flags parameter that a count could be confused with. Here re.I makes the first X match, and count 1 stops after it: -xXx.

  13. Question 13 of 16 A form field may hold exactly cat or category. Which check accepts those two words and nothing else?

    Choose one answer.

    Show the answer: Question 13 of 16

    Answer: re.fullmatch(r'cat|category', value)

    Run on Python 3.14, re.match(r'cat|category', 'catalogue') matches cat, while re.fullmatch refuses catalogue and accepts category. A validator needs the whole value checked, which is what fullmatch does.

  14. Question 14 of 16 What does re.compile(r'\p{L}') do in Python 3.14?

    Choose one answer.

    Show the answer: Question 14 of 16

    Answer: It raises PatternError: bad escape \p at position 0

    re reports unknown escapes made of a backslash and an ASCII letter as errors, so \p, \X and \G all give bad escape. A class of letters can be built from unicodedata instead.

  15. Question 15 of 16 How many items does re.findall(r'a{,2}', 'aaaaa') return in Python 3.14?

    Type a number.

    A hint

    a{,2} means a{0,2}: up to two letters per match, and the empty string counts too.

    Show the answer: Question 15 of 16

    Answer: 4 items

    With no lower number the bound means zero to two. The matches are aa, aa, a and finally the empty string at the end of the text: four items.

  16. Question 16 of 16 A chat feature must count what readers see as single characters, a Hindi letter with its vowel sign counted once, and the project already pins third-party packages. Which way fits best?

    Choose one answer.

    Show the answer: Question 16 of 16

    Answer: Add the regex module and count the matches of \X

    The deciding questions of the trade-off table are graphemes and dependencies. A grapheme needs \X, which re lacks, and the project can add a package, so the regex module fits.

Interview questions

Warm-up: what does re.sub do when its replacement is a function and not a string?

It calls the function once for every match, from left to right, and passes it the match object; the string the function returns takes the place of that match, and the text between matches is copied unchanged. So the replacement can be computed from what was matched: m[0] is the whole match, m['name'] a named group and m.start() its position. Doubling every number, re.sub(r'\d+', lambda m: str(int(m[0]) * 2), 'a3 b4'), gives a6 b8. Two rules come with it. The function must return a str, since an int stops the call with a TypeError. And the returned text is used as it is: a template escape such as \g<1> means something only in a replacement string.

Two harder questions build on the scanner and on the choice of the last section. Sketch an answer to each before you open its first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    You are writing a tokenizer in Python for a small rule language: numbers, names, the operators +, -, *, /, < and <=, and parentheses. Every error must give a line and a column. How would you build it with re, in what order do the alternatives go, and how do you make sure no character is skipped silently?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    One parser in a large Python codebase needs \p{Script=Devanagari} and whole graphemes, so a teammate proposes replacing every import re with import regex as re. Make the case for and against, and say how you would make the change, if at all, so that no other pattern in the codebase starts matching something new.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why scan records with one pattern and re.finditer rather than one findall per field?

    Each match carries every field of one record, and a missing field is None in its own record. Separate lists pair fields only by position, so one missing reference shifts every record after it.

  2. What does re.sub do when the replacement is a function?

    It calls the function once per match, left to right, with the match object, and the str it returns takes the match's place: re.sub(r'\d+', lambda m: str(int(m[0]) * 2), 'a3 b4') gives a6 b8. Returning an int raises TypeError.

  3. Why is int(float(amount.replace(',', '')) * 100) wrong for converting an amount to paise?

    Float arithmetic turns 19.99 into 1998 and 2.30 into 229. Capture the whole part and the fraction as two groups and join their digits before a single int().

  4. What does re.split add to its result when the pattern has a capturing group?

    Each captured separator, between the pieces it separated; a group that took no part adds None. Empty matches count too: re.sub(r'a*', '-', 'baac') gives -b--c-.

  5. How should count, maxsplit and flags be passed to re.sub, re.subn and re.split?

    By keyword, such as flags=re.I or maxsplit=1. Since Python 3.13, passing them by position is deprecated and raises a warning that names the parameter.

  6. What does Python's re lack that the third-party regex module adds?

    A match timeout, \p{...} properties and \X for grapheme clusters, plus fuzzy matching. Adding the module is a dependency decision your project makes.

Key takeaways

  • One pattern per record, scanned once with re.finditer, keeps every record’s fields together, while one findall per field pairs the fields only by position and shifts them all when a single record lacks one.
  • A function passed to re.sub runs once per match, left to right, and the str it returns takes the match’s place, which is how an amount with grouping commas becomes an exact whole number of its smallest unit without a float.
  • A capturing group in a re.split pattern keeps each separator in the result, a group that took no part adds None, and an empty match counts as a match, so a* turns baac into -b--c-.
  • Since Python 3.13, count, maxsplit and flags go by keyword; a pattern used in several places is compiled once, and fullmatch is the call that checks a whole value.
  • re has no timeout, no \p{…} and no \X; the third-party regex module adds them, with fuzzy matching, and adding it is a dependency decision your project makes.

Terms in this lesson

compiled pattern
The object that re.compile returns, a re.Pattern: the pattern parsed once with its flags fixed, so its search, finditer, sub and fullmatch methods take the text but no flags argument.
fuzzy match
A match that allows a set number of wrong characters, each one inserted, deleted or substituted. The third-party regex module for Python writes the limit after a group, as {e<=1}; re only matches exactly.
match timeout
A limit on how long one matching call may run, after which the engine gives up with an error instead of an answer. Python's re has none; the third-party regex module takes timeout= in seconds.
scanner
Code that reads a text from left to right with one pattern and handles each match in turn, as a loop over re.finditer does. A tokenizer is a scanner whose alternatives name the kind of each token.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.