Setting the 2027 stable style

Open
#5,658 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
25/100
Issue type
Feature
Clarity
Mostly clear
Activity status
Active
Tech stack
python

Research direction

Read the 2026 style issue (#4875), the linked proposals and bug reports, and the diff-shades runs listed here. Decide which formatting changes should become stable in 2027, considering their churn, user feedback, and unresolved issues; the outcome is a settled set of styles to stabilize.

Written by the indexing model from the issue text.

Description

2026: #4875
Ruff: https://github.com/astral-sh/ruff/issues/30022

While many of the "controversial" changes may seem like objectively better formatting to some, the current formatting is well established, and stabilizing some of these larger changes would cause a lot of churn for all users.

Ordered by most controversial to least:

hug_parens_with_braces_and_square_brackets

Input

func(
    {
        "a": 37,
        "b": 42,
        "c": 927,
    }
)

Output (off)
unchanged

Output (on)

func({
    "a": 37,
    "b": 42,
    "c": 927,
})

Currently in --unstable, but the crash in #4036 has been fixed. We should probably at least move it from unstable to preview.
Causes lots of changes across many many lines, but it's always a decrease and generally increases readability.
Proposed changes:

  • #4099 - note this example is currently unchanged from stable style, so we could implement this post-stabilization without churn
  • #4098 - in some senses, this is asking for the opposite as the previous

diff-shades-run

wrap_long_dict_values_in_parens

Input

{
    "a key in my dict": a_very_long_variable
    * and_a_very_long_function_call()
    / 100000.0,
    "another key": (short_value),
}

Output (off)
unchanged

Output (on)

{
    "a key in my dict": (
        a_very_long_variable * and_a_very_long_function_call() / 100000.0
    ),
    "another key": short_value,
}

Could cause a lot of churn. Adds more parentheses and sometimes more lines to reduce line length. Defered for many years (since 2023).
Ruff has decided this is a non-goal (astral-sh/ruff#12856, #4123).
Most of the negative feedback from the past few years (#4882 covers much of it) is fixed by the currently unmerged #5627 - for expressions like single names or strings, parens are retained as-written.

diff-shades-run

split_implicit_string_concatenations_at_newlines

Input

ValueError(
    "Invalid input:\n"
    f" * x={x}\n"
    f" * y={y}\n"
    f" * z={z}"
)
a = (
    "<p><label>foo bar baz:</label>"
    '<div id="id_somechoice">\n'
    "<p><label>foo bar baz:</label>"
    '<div id="id_somechoice">\n'
    '<div><label for="id_somechoice_0">'
    '<input type="radio" id="id_somechoice_0" value="foo" name="somechoice" '
    "required> Lorem Ispum Dolor</label></div>\n"
)

Output (off)
unchanged

Output (on)

ValueError("Invalid input:\n" f" * x={x}\n" f" * y={y}\n" f" * z={z}")
a = (
    "<p><label>foo bar baz:</label>" '<div id="id_somechoice">\n'
    "<p><label>foo bar baz:</label>" '<div id="id_somechoice">\n'
    '<div><label for="id_somechoice_0">'
    '<input type="radio" id="id_somechoice_0" value="foo" name="somechoice" '
    "required> Lorem Ispum Dolor</label></div>\n"
)

I think this is a good change. In many cases it's more inconsistent (i.e the second example is broken after some strings but not others), but I think it may encourage people to manually join/split strings at \n, which makes them much more readable.
Obviously the ideal solution would be #1467 but that's its own can of worms.

diff-shades-run

wrap_comprehension_in

Input

result = [
    very_very_very_very_very_long_item
    for very_very_very_very_very_long_item in some_very_very_very_very_very_very_long_function_name
]
filtered_trait_instructions: list[str] = [
    trait for trait in (traits_instructions or []) if trait
]
filtered_trait_instructions: list[str] = [
    trait for trait in traits_instructions or [] if trait
]

Output (off)
unchanged

Output (on)

result = [
    very_very_very_very_very_long_item
    for very_very_very_very_very_long_item in (
        some_very_very_very_very_very_very_long_function_name
    )
]
filtered_trait_instructions: list[str] = [
    trait for trait in (traits_instructions or []) if trait
]
filtered_trait_instructions: list[str] = [
    trait for trait in traits_instructions or [] if trait
]

Black currently doesn't add or remove parentheses here at all, so this change tries to standardize them. Black now auto-wraps these clauses if it allows it to fit within the line length, and also prefers wrapping clauses rather than splitting within them.
Changed after last year's feedback to retain the written parentheses if it wouldn't be broken across multiple lines. That also heavily decreased the scope of the change.

diff-shades-run

symmetric_collection_operations

Input

names = ["Alice", "Bob", "Charlie", "Diana", "Edward"] + [
    "Fiona",
    "George",
    "Harriet",
    "Isabelle",
]

Output (off)
unchanged

Output (on)

names = (
    ["Alice", "Bob", "Charlie", "Diana", "Edward"]
    + ["Fiona", "George", "Harriet", "Isabelle"]
)

Applies with any operation between any collections with the goal of visual symmetry. Has a narrow scope and clearly improves aesthetics, but I could also see it going either way with the community.
Depending on the reception to this change, we may consider #3629/#4455 which expand this style to other formats. However, it's unlikely due to the large scope of those changes, unlike this one, which is very localized. See those issues and their associated PRs for more details.

diff-shades-run

keep_dict_keys_with_operators

Input

tests = {
    Timestamp("2014-07-04 15:00")
    + Nano(5): this_is_a_very_long_function("2014-07-04 16:00"),
}

self.message_user(
    request,
    gettext("Add another %(verbose_name)s")
    % {
        "verbose_name": capfirst(verbose_name),
    },
)

Output (off)
unchanged

Output (on)

tests = {
    Timestamp("2014-07-04 15:00") + Nano(5): this_is_a_very_long_function(
        "2014-07-04 16:00"
    ),
}
self.message_user(
    request,
    gettext("Add another %(verbose_name)s") % {
        "verbose_name": capfirst(verbose_name),
    },
)

Should be an objective bug fix. In some cases could increase line count (if the wrapped RHS is more lines than the wrapped LHS) but should increase readability.
The % change was an unintentended but also generally improves readability as well. If anything I could see the % change being controversial and may need to be moved into its own style.

diff-shades-run

pyi_overload_group_blank_lines

Input

@overload
def foo(): ...
@overload
def foo(): ...
@overload
def foo(): ...
@overload
def bar(): ...
@overload
def bar(): ...
@overload
def baz(): ...

Output (off)
unchanged

Output (on)

@overload
def foo(): ...
@overload
def foo(): ...
@overload
def foo(): ...

@overload
def bar(): ...
@overload
def bar(): ...

@overload
def baz(): ...

Seems like an objectively good change to me. For reference, it was requested and implemented by a ty maintainer.

diff-shades-run

From here below, I personally believe these changes are objective improvements or bugfixes we don't need to think twice about stabilizing

remove_redundant_list_parentheses

Input

([])
([1, 2])
foo(([1, 2]))

Output (off)
unchanged

Output (on)

[]
[1, 2]
foo([1, 2])

Uncontroversial at face value. Almost always decreses line length or indentation level. Some rare cases where it introduces more lines, but generally increases readability.

diff-shades-run

remove_redundant_subscript_parentheses

Input

[][(1)]
a[(1, 2)]

Output (off)
unchanged

Output (on)

[][1]
a[1, 2]

Should generally be uncontroversial, but some people may prefer to retain the parentheses to highlight that it's a tuple index and not an integer. Never adds parenthesis.
Outstanding bug #5619, but will rarely be found in practice (only applies to <=3.9 targets) and isn't necessarily blocking.

diff-shades-run

remove_redundant_unpacking_parentheses

Input

(a), (b) = 1, 2
((a), (b)) = 1, 2
[(a), (b)] = [1, 2]
a, (b, (c)) = 1, (2, 3)

Output (off)
unchanged

Output (on)

a, b = 1, 2
a, b = 1, 2
[a, b] = [1, 2]
a, (b, c) = 1, (2, 3)

Also applies to for loops, comprehensions, with statements, etc. Never adds parentheses. Could be somewhat controversial in some cases, such as highlighting that it's being assigned to a tuple.

diff-shades-run

remove_redundant_comparison_parentheses

Input

assert (var) == (1)

Output (off)
unchanged

Output (on)

assert var == 1

Should be relatively objective. If it exceeds line length, it prefers to split before the comparison operator rather than inside one of the operands.

diff-shades-run

simplify_power_operator_hugging

Input

a = 1**1
b = 1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1**1

Output (off)

a = 1**1
b = (
    1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
    ** 1
)

Output (on)

a = 1**1
b = (
    1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
    **1
)

For the most part, just a simplification of the current code. Removing the spaces after a broken power operator is a side effect that does make formatting more consistent. Not sure if it's a good change visually, or if it's even worth trying to fix this due to the rarity of the form.

diff-shades-run

remove_redundant_top_level_parentheses

Input

(x)
(1)
(yield 42)
([])
({})
(a + b)

Output (off)
unchanged

Output (on)

x
1
yield 42
[]
{}
a + b

Shouldn't affect most code in practice, since these are all no-ops.

diff-shades-run

parenthesize_tuple_in_yield

Input

def f():
    yield x,
    x = (yield a, b)
    (
        yield long_variable_name_one,
        long_variable_name_two,
        long_variable_name_three,
        long_variable_name_four,
    )

Output (off)
unchanged

Output (on)

def f():
    yield (x,)
    x = yield a, b
    yield (
        long_variable_name_one,
        long_variable_name_two,
        long_variable_name_three,
        long_variable_name_four,
    )

Relatively objective stylistic change

diff-shades-run

hug_comparator

Input

x = [
    t
    for t in y
    if t not in {
        NameOne,
        NameTwo,
        NameThree,
    }
]

Output (off)

x = [
    t
    for t in y
    if t
    not in {
        NameOne,
        NameTwo,
        NameThree,
    }
]

Output (on)
unchanged

Objective formatting fix. This split happens for no visual reason.

diff-shades-run

normalize_tstring_prefix

Input

x = T"Text {name}"
x = t"Text {name}"

Output (off)
unchanged

Output (on)

x = t"Text {name}"
x = t"Text {name}"

Very localized and should be uncontroversial.

diff-shades-run

remove_redundant_generator_parentheses

Input

any((item.is_valid() for item in items))
[((item for item in items)), fallback]

Output (off)
unchanged

Output (on)

any(item.is_valid() for item in items)
[(item for item in items), fallback]

Objective formatting change. Never adds parentheses, only removes them.

diff-shades-run

avoid_parens_for_unbreakable_rhs_in_assignments

Input

class A:
    raw = "this_is_very_very_long_this_is_very_very_long_this_is_very_very_long_this_is_very_very_long"
    attr: str = (
        "this_is_very_very_long_this_is_very_very_long_this_is_very_very_long_this_is_very_very_long"
    )

Output (off)
unchanged

Output (on)

class A:
    raw = "this_is_very_very_long_this_is_very_very_long_this_is_very_very_long_this_is_very_very_long"
    attr: str = "this_is_very_very_long_this_is_very_very_long_this_is_very_very_long_this_is_very_very_long"

The strings aren't wrapped because they don't fit within the line length either way.
Purely a bug fix to make this apply to annotated as well as unannotated assignments. Only removes parentheses, doesn't add any.

diff-shades-run

fmt_off_class_blank_lines

Input

import sys


# fmt: off
class C:
    def foo():
        ...

Output (off)

import sys

# fmt: off
class C:
    def foo():
        ...

Output (on)
unchanged

Objective bug fix - two lines are already added without the comment

diff-shades-run

fix_unnecessary_parens_in_indexed_assignment

Input

dictionary_of_arrays["long_key_name_for_the_example"][
    very_long_index_name, index_zero
] = (10 - 5)

Output (off)
unchanged

Output (on)

dictionary_of_arrays["long_key_name_for_the_example"][
    very_long_index_name, index_zero
] = 10 - 5

Objective bugfix

diff-shades-run

end_fmt_off_at_closing_bracket

Input

print(
    # fmt: off
    a
)

Output (off)

print(
# fmt: off
    a
)

Output (on)
unchanged

Objective bugfix. Also fixes a crash in similar shapes with arrays.

diff-shades-run

parenthesize_expressions_with_comments

Input

long_variable_name_for_testing_purposes_only = some_long_function_name_for_testing_purposes(
    first_argument,
    # comment between parameters
    second_argument,
)

Output (off)
unchanged

Output (on)

long_variable_name_for_testing_purposes_only = (
    some_long_function_name_for_testing_purposes(
        first_argument,
        # comment between parameters
        second_argument,
    )
)

Objective bug fix - it's properly wrapped on stable without the comment.

diff-shades-run

blank_line_after_stub_method

Input

class Foo:
    def test(): ...
class Bar: ...

Output (off)
unchanged

Output (on)

class Foo:
    def test(): ...


class Bar: ...

Purely a bug fix. If test() uses pass instead, it already adds blank lines.

diff-shades-run

pyi_blank_line_after_function_docstring

Input

class Example:
    def method(self) -> None:
        """Documentation."""
    # comment for the next member
    attr: int

Output (off)
unchanged

Output (on)

class Example:
    def method(self) -> None:
        """Documentation."""

    # comment for the next member
    attr: int

Bugfix - we currently add blank lines between two class methods, but not between a class method and anything else.

diff-shades-run

pyi_blank_line_before_decorated_class

Input

def foo(): ...

@decorator
class Bar: ...

def baz(): ...
@decorator
class Spam: ...

Output (off)

def foo(): ...
@decorator
class Bar: ...

def baz(): ...
@decorator
class Spam: ...

Output (on)

def foo(): ...

@decorator
class Bar: ...

def baz(): ...

@decorator
class Spam: ...

Objective bugfix - we currently add blank lines before class defs, but not if they're decorated

diff-shades-run

keep_commented_expressions_together

Input

foobar = [
    # comment
    pathlib.Path("foo")
    / "bar"
    / "baz",
]

Output (off)
unchanged

Output (on)

foobar = [
    # comment
    pathlib.Path("foo") / "bar" / "baz",
]

Purely a bug fix. Short expressions are already wrapped this way without the comment.

diff-shades-run

fix_if_guard_explosion_in_case_statement

Input

match x:
    # good refactor
    case [
        y
    ] if y == 123:
        pass
    case [
        y
    ] if True:
        pass
    case [
        y,
    ] if True:
        pass

    # bad refactor
    case [
        y,
    ] if y == 123:
        pass

Output (off)

match x:
    # good refactor
    case [y] if y == 123:
        pass
    case [y] if True:
        pass
    case [
        y,
    ] if True:
        pass

    # bad refactor
    case [
        y,
    ] if (
        y == 123
    ):
        pass

Output (on)

match x:
    # good refactor
    case [y] if y == 123:
        pass
    case [y] if True:
        pass
    case [
        y,
    ] if True:
        pass

    # bad refactor
    case [
        y,
    ] if y == 123:
        pass

Purely an objective bug fix.

diff-shades-run

fix_magic_trailing_comma_trailer_split

Input

b = [1,][2](3)

Output (off)

b = [
    1,
][
    2
](3)

Output (on)

b = [
    1,
][2](3)

Purely an objective bug fix.

diff-shades-run

keep_trailers_on_bracket_comment_overflow

Input

_very_very_very_long_function_call_name(
    very_very_very_very_long_variable_name.get_data("key")[0].get_nested(  # type: ignore
        "nested_key"
    )[0]
)

Output (off)

_very_very_very_long_function_call_name(
    very_very_very_very_long_variable_name.get_data("key")[0].get_nested(  # type: ignore
        "nested_key"
    )[
        0
    ]
)

Output (on)
unchanged

Purely a bug fix. Without the comment, the call isn't broken at all, and the split is needed within the indexing operator.

diff-shades-run

Other changes to implement for 27.1: #5269 #5629 #5576

Dominant language
Python
Stars
42.2k
Forks
3k
Avg merge
2d 8h
Merged PRs (30d)
96

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from psf/black

All issues in psf/black

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.