Null Literal Unpacking

Prior discussion thread: PEP 802: Display Syntax for the Empty Set - #166 by gesslerpd

This is a request for PEP sponsors and general continuation of this specific topic.

Preamble

PEP: TBD
Title: Null Literal Unpacking
Author: Peter Gessler
Discussions-To: Pending
Status: Draft
Type: Standards Track
Python-Version: TBD
Post-History:

Abstract

We propose allowing a bare * to contribute no elements when constructing a tuple, list, or set:

Change New forms Meaning
Empty tuple (*,); empty_tuple = *, where parentheses are optional Construct () while retaining the tuple-forming comma. Allows empty tuple without parentheses.
Empty set {*}, {*,} Construct an empty set without looking up set.
Empty list [*], [*,] Construct [] using the same rule for symmetry (no benefit).
Null entries alongside elements for incremental construction (*, x), [*, 1], {*, 1} Produce (x,), [1] and {1}.

The main benefits are:

  • Stable construction: retain a null entry while adding or removing ordinary elements, without changing the container kind at the zero/one-element boundary.
  • An unambiguous empty-set form: derive {*} from the same collection literal rule used for tuples and lists, leaving {} as a dictionary.
  • A simpler teaching pattern: introduce *, as a disambiguated leading comma before explaining unpacking and pre-existing special-case pitfalls (connection to unpacking can be deferred until introducing that concept).
  • No new runtime overhead or AST changes: null entries contribute nothing and are omitted from the container’s AST element list.

A retained null entry lets ordinary elements be added or removed without changing the container kind. The empty-set spelling and the tuple’s comma-bearing empty form follow from the same rule. Existing construction forms remain valid. Whether {*} should also become the empty set’s repr is left open.

Motivation

Adding or removing a collection element should not accidentally change the kind of object being constructed. With conventional set and tuple spellings, an otherwise local edit can do exactly that:

item = 1

members = {item}      # A set.
members = {}          # A dictionary, not an empty set.

coordinates = ()      # An empty tuple.
coordinates = (item)  # The item itself, not a tuple.
coordinates = (item,) # A one-element tuple.

A retained null entry makes the construction explicit independently of the number of ordinary elements. {*,} can grow into {*, item}, and (*,) into (*, item), without changing the surrounding syntax. Removing those elements reverses the edit without turning a set into a dictionary or a tuple into grouping.

Where parentheses are optional, the same rule gives values = *,, values = *, item, and values = *, item, another_item. The empty tuple then uses the same comma-bearing form as its non-empty counterparts.

This proposal addresses those transitions, not the length of set() or () in isolation. The shorter empty-set spelling is a useful consequence of a construction rule that also works for tuples and lists.

Rationale

PEP 448 allows iterable unpacking among the elements of tuple, list, and set literals. [1] We extend that model with an explicit zero-element entry: *items contributes the elements of items, while a bare * contributes none. It denotes neither None nor a new runtime value.

The distinction between {*items} and {**mapping} already identifies iterable unpacking as set construction. {*} therefore has an unambiguous meaning without changing {} or introducing a new token. Lists follow the same rule as the tuple and set forms; [] remains the usual empty-list spelling.

“One obvious way” does not require a single spelling for every value: list() and [], tuple() and (), and set(iterable) and set literals already coexist.

Null entries may occur anywhere and may be repeated, following PEP 448’s treatment of iterable unpacking. Restrictions that might catch mistakes are considered under “Open Issues”.

We prefer this generalization to an isolated empty-set notation because one rule addresses both empty-set construction and tuple-preserving edits. It does not eliminate the existing special cases; it provides a construction pattern that avoids them when used.

PEP 802 proposes {/} and a matching empty-set representation. [6] This proposal derives {*} from the shared unpacking rule; adopting it for repr as well is a separate choice discussed under “Open Issues”.

Specification

A bare * is permitted as an entry in a tuple, list, or set literal, and in unparenthesized tuple construction where starred entries are already allowed. It contributes no elements and performs no evaluation, name lookup, or iteration.

The following forms are equivalent, assuming the usual built-in bindings:

empty_tuple = *,
empty_set = {*}
empty_list = [*]

assert empty_tuple == tuple() == () == (*,)
assert empty_set == set() == {*,}
assert empty_list == list() == [] == [*,]

Null entries may appear in any position and may be repeated or mixed with ordinary elements and iterable unpackings:

assert (*, item) == (item, *) == (item,)
assert [1, *, *[2, 3]] == [1, 2, 3]
assert {*, 1, 1, *} == {1}
assert (*, *) == ()

The enclosing syntax determines the container kind before null entries are discarded. Thus {*} is a set, and (*, item) is a tuple even though only one element remains. A tuple-forming comma is still required: standalone * and (*) remain syntax errors, just as standalone *() and (*()) are today.

Ordinary elements and operand-bearing unpackings keep their existing evaluation order and behavior. [2] In particular, [*None] still raises TypeError. List and set literals produce fresh mutable containers; tuple identity remains an implementation detail. No particular bytecode sequence is required.

Grammar

The affected productions can be expressed as follows, omitting semantic actions and diagnostic rules. [3][5]

literal_item: star_named_expression | '*'
literal_items: ','.literal_item+ [',']
list: '[' [literal_items] ']'
tuple: '(' [literal_item ',' [literal_items]] ')'
set: '{' literal_items '}'

tuple_item: star_expression | '*'
star_expressions:
    | tuple_item (',' tuple_item)+ [',']
    | tuple_item ','
    | star_expression

subject_expr:
    | literal_item ',' [literal_items]
    | named_expression

Operand-bearing alternatives retain precedence. Only these construction productions are extended; other starred-expression rules and the usual rules for nesting expressions remain unchanged.

Abstract syntax tree

The parser omits null entries from the container’s element list. No new public AST node is required. [4]

Source AST, omitting location attributes
[*] List(elts=[], ctx=Load())
{*} Set(elts=[])
(*,) Tuple(elts=[], ctx=Load())

Context validation must precede omission so an invalid target such as [*] = [] is not accidentally accepted. Operand-bearing unpackings remain ordinary Starred nodes.

Source locations follow the usual conventions. Concrete-syntax tools may retain the original spelling; AST-only tools, including ast.unparse, need not.

Backwards Compatibility

The syntax change accepts previously invalid forms without reinterpreting valid source. Existing construction expressions require no migration; using the new syntax requires updated interpreters and parser tooling.

Some missing-operand mistakes would cease to be syntax errors. The tradeoff and possible restrictions are discussed under Additional constraints in “Open Issues”.

Changing the empty-set representation to '{*}' would have a separate compatibility cost: repr, str, and the printed forms of enclosing containers would change. Doctests, snapshots, and code expecting 'set()' could need updates, and older Python versions could not evaluate the new spelling. Retaining the current representation avoids these output changes.

Security Implications

None. Null entries execute no code and introduce no implicit calls. Ordinary elements and iterable unpackings retain their existing behavior.

How to Teach This

Beginners don’t need to learn unpacking first. A convenient starting point is to read *, as a disambiguated leading comma: keep this prefix in place and write the real elements after it.

members = {*,}
members = {*, item}
members = {*, item, another_item}

coordinates = (*,)
coordinates = (*, x)
coordinates = (*, x, y)

The star contributes no element. Inside braces, it identifies set construction rather than dictionary construction; in the tuple forms, the comma distinguishes a tuple from grouping. The punctuation stays the same for zero, one, or many elements. There is no need to switch to a constructor call when the set becomes empty or add a distinguishing comma when the tuple gains its first element.

This offers a way to teach disambiguated leading-comma notation, not a separate grammar feature: bare forms such as (, x) and {,} remain invalid, as does (*).

Unpacking concept can be introduced later through the same rule:

In a tuple, list, or set literal, *items contributes the elements of items; a bare * contributes none.

The tutorial can present the leading-marker pattern before explaining this generalization. Existing spellings still need to be recognized when reading code, and the AST documentation should explain normalization.

Rejected Ideas

These alternatives are not adopted in this draft. Positional restrictions remain open.

Add only an empty-set spelling

PEP 802’s {/}, or a special case accepting only {*}, would solve the empty-set problem. Neither would provide the shared zero-element entry used to preserve tuple construction across edits. This PEP strives to allow avoidance of special-cases in the language and both add a new special-case. Although, special-casing {*} would keep the door open for a larger scope PEP like this to exist later.

Add operandless **

Dictionaries already have the unambiguous empty spelling {}. Adding {**} would improve symmetry but is unnecessary for the concrete set and tuple benefits.

Open Issues

Empty-set representation

Should {*} also become the representation of the empty set? This would make interactive output reinforce the new notation and align the printed form with its construction syntax.

If adopted, repr(set()) and str(set()) would return '{*}' for built-in empty sets, regardless of how they were constructed. Non-empty set representations would be unchanged. This is independent of accepting the new syntax: retaining 'set()' remains an option. The compatibility costs are described under Backwards Compatibility.

Additional constraints

The current specification allows null entries in any position and permits repetition. We could instead allow a null entry only in the first syntactic position: (*, item) would remain valid, but (item, *) would not.

This would preserve the leading-placeholder pattern and catch mistakes such as accidentally changing [head, *tail] to [head, *]. It would not catch an omitted operand in the first position: changing [*items] to [*] would still be valid.

The cost is another special case. Ordinary unpacking can occur in any element position, but its zero-element form would be allowed only first, even though its position has no effect on the result.

Allowing at most one null entry is another option. It would reject [*, *] without catching the missing operand in [head, *], and would be redundant under a first-position-only rule. Alternatively, documentation and linters could recommend leading placeholders without restricting the grammar.

The choice is between catching some mistakes and keeping the construction rule uniform. None of these restrictions would change the meaning of an accepted null entry.

Function call contexts

Should null unpacking also be allowed wherever a *() argument is valid today? If so, print(*) would pass no arguments, like print(*()) or print(), and print(*, value) could retain a leading argument placeholder.

This would extend the same unpacking rule to calls, but calls already have an unambiguous empty form. The main benefit would be a placeholder for future arguments. It would also turn an accidental change from print(*values) to print(*) into a valid call that silently omits the intended arguments.

The current specification limits null unpacking to literal collection construction, preserving that syntax error. Extending it to call arguments remains an open expansion. This concerns a bare star in an argument list, not literals passed as arguments.

Remaining decisions

The target release and coordination with PEP 802 remain to be settled. [6]

Acknowledgements

PEP 448’s generalization of iterable unpacking provides the foundation for this proposal. [1]

All contributors to the related prior PEP 802 discussion thread: PEP 802: Display Syntax for the Empty Set

References

[1] PEP 448 — Additional Unpacking Generalizations.

[2] Python Language Reference — Expressions.

[3] Python Language Reference — Full Grammar Specification.

[4] Python Standard Library — ast.

[5] CPython’s grammar and parser helpers.

[6] PEP 802.

2 Likes

As I don’t particularly miss a special way to write empty set, I’m lukewarm on any proposal to add it.
With that in mind, this proposal admittedly brings some logic: A lone star also means “no args but this would be the place for it” in a function definition:

def func(a, b, *, c): …
3 Likes

What about dicts? The current proposal adds redundant spellings for tuples and lists without decreasing the number of exceptions. (It just traded “no empty-set display” for “no asterisk analog for dicts”.)

3 Likes

Function definition context is not applicable for this proposal. Also already used: PEP 3102 – Keyword-Only Arguments | peps.python.org

Proposal applies to contexts in which you could use *() in Python today and is best way to play around with the idea.

Should discuss if it’d be allowed in function call contexts or limited in grammar to avoid print(*args) → print(*) typos. But could act as simple argument placeholder trick like you mentioned @Dutcho .

Added a section about this Null Literal Unpacking

This could be added, would complete symmetry but listed as out of scope for now since it’s not as beneficial.

Proposal, as-is can represent 0 -> 1 -> 0 set literal transitions without awkward switch to set() using pre-seeded {*,}.

Open to adding {**} to proposal for symmetry, it was included in original thread, in practice there’s no reason for dict/list literals to use this.

To me this is unnecessary and malbodge-y. -1. -2 even.


looks like C++ tier [=](auto &&x) {} visual pollution of punctuation.


The two extra forms, [*] and [*,], seems a bit much (as much as I appreciate being able to use trailing commas in a dict compared to JSON in which one can’t).


It’s trying to solve a problem that doesn’t exist. Eg we already have [] that acts as an empty list literal that can be expanded without removing any character first.


Should this really be called ‘null’ unpacking which may be confused with None, when it’s more of a ‘noop’ unpacking that gets skipped over taking up no space, rather than a null that acts as a placeholder taking up 1 unit of space?


Tuples with 1 item needing a trailing internal comma (67,) and an empty set literal requiring an extra character (eg / in {/}) to disambiguate from the empty dict are fine special cases, the former already being an accepted part of the language.

Yes one would have to remove the / first to expand an empty set {/} with PEP 802 syntax, but so be it. It won’t kill a git diff. I’d rather have to remove the / than leave *s everywhere.

This proposal just sprinkles white noise * noops all over the place.

3 Likes

-1

If * is (), wouldn’t we expect {*} to be {()} and [*] to be [()]?

I’m still in favour of the {*()} AST improvement, to remove the intermediate tuple construction, though may not be worth implementing it. I assume that’s not part of this proposal, if empty_set = * would supersede it…

1 Like

Tuple forming comma still required, *, is empty tuple. Contexts in which tuple literals can be made without parentheses.

Allows transition from 0 → 1 → many length tuple and back with/without parentheses.

1 Like

Ah, gotchya, need the comma, sorry.

Seems like we’d still have the same issue with *, being (), so [*,] should be [()]? I guess I should think of *, as a nullary operator, rather than an element…? I don’t see it as a natural extension of existing syntax.

2 Likes

It’s trying to provide a way to do this with sets/tuples. List is for feature symmetry only and not useful in practice.

The “Null” is for “null set” or “emtpy set”, could be a better name for it. “Empty Unpack Literal”?

Posted the idea in its own thread because of the support it got in the “empty-set” thread. It probably shouldn’t be sprinkled many places, but it provides a generalized solution to empty-set literal without that being it’s only use-case.

1 Like

No issue, tuple formation w/o parentheses only allowed in some contexts.

Just as *iterable, is a tuple today, [*iterable] is list with/without the trailing comma. Inside the list to form a nested tuple would require parentheses.

Aside from the empty tuple, when can’t you form a parens-less tuple? The only place I can think of is a function’s parameters, where an unparenthesized comma would move to the next argument.

Keys in a dict literal: {1, 2: 3} is a syntax error

2 Likes

All the places that “Starred” unpacking is valid today. Inside function calls and other literal containers. [*range(1),] == [0] need parentheses to make it form nested tuple inside list.

I’m missing any details about the most important use-case IMHO:

opt_verbose = True
opt_recursive = False
cmd = ["cp", "-v" if opt_verbose else *, "-r" if opt_recursive else *, src, dst]
assert cmd == ["cp", "-v", src, dst]
1 Like

So if a one-liner was originally used, [*((a,) if c else ()), *((b,) if d else ())] becomes [a if c else *, b if d else *]. Some characters are definitely saved and the ugly parentheses no longer arise, but i usually see these argv-style lists built using sequential appends in real code, which may be more readable. The proposal would inadvertently encourage run-on lines.

2 Likes

I think the "-v" if opt_verbose else * is a conflation of features here, but I want to pull at a common thread that I don’t think I’ve seen articulated in these threads. (Apologies if that’s because it’s deeply obvious to everyone.)

List comprehensions are one of Python’s defining features, and they intentionally look like a variant on list literal construction. As unpacking was added to literals and (with PEP 798) comprehensions, the similarities grow, and the more unnatural it feels to hit a syntax error when you use a concept from one in the other.

For example, [*range(2, 5)] is a valid literal and is a way of compressing [x for x in range(2, 5)], but while [1, *range(2, 5)] is valid, [1, x for x in range(2, 5)] is a syntax error. If I want to combine the two, for example to use predicates, I need to double-unpack:

[0, *(*range(i, i+2) for i in range(8) if i % 2)]

While obviously you can say “don’t do things like this”, this is tantalizingly close to a unified DSL:

mylist = [
    # Existing literal syntax
    val1,                                        # Literal
    val2 if condition1 else val3,                # Ternary
    *iter4,                                      # Unpacking
    # Unavailable
    val5 if condition2,                          # Binary conditional
    # Standard comprehension
    f(val6) for val6 in iter7 if pred1(val6),    # Comprehension
    # PEP 798 comprehension
    *g(val8) for val8 in iter9 if pred2(val8),   # Unpacked comprehension
    # Unavailable
    *,                                           # Null unpacking
]

I don’t know if the practicalities of the grammar mean the two cases have to be handled separately, but the incomplete overlap seems to be why these ideas keep coming up.

To me, the main argument against each of the ideas in isolation is that the motivating use cases are a bit niche, and saving a couple characters/lines is not worth complicating the language. If I were to argue for them, it would be to fully unify these two ways of constructing lists/tuples/sets/dicts, which would be a conceptual simplification. I would argue for the binary conditional for consistency with comprehension predicates.

The null unpacking is still a bit questionable in value, to me, but I don’t hate it. I think placeholders have their uses, and if it indicates the set/dict-ness in otherwise empty brackets, that’s a neat bonus.

I would definitely argue that what’s good for the sequence is good for the mapping. I don’t see a reason to support * and not **.

9 Likes

-1. * already means two things: unpacking ([*xs], f(*args)) and the keyword-only marker (def f(a, *, b)). A third meaning (an element that isn’t there) is one too many

1 Like

Matter of perspective I suppose, this proposal is meant as extension to the “unpacking” meaning. Only valid where unpacking is valid and means “unpack nothing” but useful for empty-set, tuple forming comma preservation, and temp placeholders.

1 Like