Setting the 2027 stable style
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
- Domain
- developer-experience, tooling
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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
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
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.
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.
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.
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.
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
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.
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.
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.
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.
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
- Ships a Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from psf/black
-
Odd behavior from `black` when handling long `lambda` functionsPossibly taken @harshitdeora claimed this 9 days ago. OpenF: linetoolong T: bug
Difficulty 3/5 1-2 days Newbie friendliness 57/100
Maintainers usually reply within 1 day
-
Project-relative cache-dir allows unrestricted pickle.load of attacker-controlled dataPossibly taken @rinopatrick claimed this 12 days ago. OpenS: needs discussion
Difficulty 4/5 3-5 days Newbie friendliness 22/100
Maintainers usually reply within 1 day
-
Compatible-release requires-python constraints infer unsupported targetsPossibly taken @harisawan-bit claimed this 108 days ago. Open
Difficulty 3/5 1-2 days Newbie friendliness 48/100
Maintainers usually reply within 1 day
-
Nested .gitignore negations can never re-include what an ancestor excludedPossibly taken @RRXXZZYY claimed this 73 days ago. Open
Difficulty 4/5 3-5 days Newbie friendliness 65/100
Maintainers usually reply within 1 day
-
`ruff:ignore` should be processed as a pragma in `contains_pragma_comment`Possibly taken @Kayvan-Zahiri claimed this 32 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 45/100
Maintainers usually reply within 1 day
Similar issues
-
add entry to blockOpen
Difficulty 1/5 Under an hour Newbie friendliness 65/100
StevenBlack/hosts#3299 ·
-
`garak --report` overwrites the input report when its name has no ".report"Possibly taken @0z5a claimed this today. Openneeds-triage
Difficulty 2/5 Half a day Newbie friendliness 72/100
Maintainers usually reply within 2 days
-
plot_graph crashes when a mesh node has no m2m edgePossibly taken @tygvi claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
mllam/neural-lam#883 · 1 comment ·
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
future-agi/future-agi#4092 · 1 comment ·
Maintainers usually reply within 1 day
-
transcript analytics: a text whose only protected spans the prose pass redacts is withheld wholeOpen
Difficulty 1/5 Under an hour Newbie friendliness 82/100
WingedGuardian/GENesis-AGI#4641 ·
Maintainers usually reply within 1 day