Repository: python-attrs/attrs

Issue:
Suggestion: Overriding class-level `kw_only=True` option in attributes
First of all, thank you for building such a wonderful package! It is now an essential part of both my work and my hobby projects.

This issue is similar to #481, but I think it's more suitable to open a new issue for discussion instead of reviving a 4-year-old thread.

`kw_only` can be set both on the class decorator and on attributes. When `kw_only=False` on the class (this is the default), one can set `kw_only=True` on attributes to make only those attributes keyword-only in the constructor. However, when `kw_only=True` on the class, all attributes (including those defined in base classes) become keyword-only, and setting `kw_only=False` on attributes will not work.

The following code snippet implements the logic described above:
https://github.com/python-attrs/attrs/blob/7091b1f89ac2436e8ba96f85a799b66c38a3c9f4/src/attr/_make.py#L564-L566

In my opinion, this is a little confusing, since many of the other options that exist on both the class and attributes do give you a way to opt-out for a subset of attributes. For example, `eq` (and friends), `repr`, and `init` all allow you to exclude an attribute from the auto-generated functions. It feels natural to assume the same for `kw_only`; I don't have exact stats, but I know quite a few coworkers who were surprised by the current behavior.

It would also be useful to allow overriding. For example, I have a class whose ideal constructor signature should look like this:
```python
SomeObject(x, y, *, kw1=..., kw2=..., kw3=..., ...)  # imagine there's a lot more kw-only args
```
where `x` and `y` are required, and all the rest are optional with defaults, usually some obscure setting that can be tweaked but are rarely tweaked. To implement such an interface, I would have to set `kw_only=False` on the class, and `kw_only=True` on most of the attributes. To make matters worse, I need to do the same for all new attributes in all of its subclasses as well. It would be beneficial if we could just set `kw_only=True` on the class, and override a few attributes with `kw_only=False`.

---

If we are to allow overriding `kw_only` on attributes, this is how I imagine it should behave:

- The class's `kw_only` defaults to False, as it does today.
- The attribute's `kw_only` defaults to None. If it is set to True or False, then it stays kw-only or non-kw-only regardless of the class's setting; if it is not set (None), then it reflects the class's `kw_only` value.
- Note that this also means that a subclass cannot control the `kw_only`-ness of its base attributes.

I'm open for debate on the semantics and I'd love input on how this can be improved or made more natural or useful.

Now, I would love to put up a pull request for this change, but I realize that this would break backwards-compability, so I'd like to seek permission from maintainers before I go ahead and do this. I'm also open to introducing a new class-level option (e.g. `kw_only_overridable`, or something less horrendous) that's disabled by default, similar to how `collect_by_mro` works. Please let me know if you think this proposal makes sense, and how we should move forward. Thanks!


Discussion:
That seems like the common semantics for other parameters, where do you expect things to break backwards-compatibility?
> That seems like the common semantics for other parameters, where do you expect things to break backwards-compatibility?

I agree it's common semantics for other parameters, but the current state is that `kw_only` doesn't follow the semantics, so changing that would result in different behaviors for existing code.

Here's a hypothetical example:

```python
@attrs.define
class Base:
  a: int = attrs.field()
  b: int = attrs.field(kw_only=False)
  c: int = attrs.field(kw_only=True)

@attrs.define(kw_only=True)
class Derived1(Base):
  pass

@attrs.define(kw_only=False)
class Derived2(Base):
  pass
```
`Base` and `Derived2` would stay the same regardless of the proposed change. But for `Derived1`:
- In the current state, it would have all three fields as kw-only, despite setting `kw_only=False` on the field `b`.
- If we change the semantics, only `a` and `c` would be kw-only, and we'd get an exception because the non-kw-only field `b` is after a kw-only field.

Although no one would write code like this, it illustrates a case where the behavior differs if we change the semantics of `kw_only`.

Of course, I support making the change since I think it's more intuitive and could reduce boilerplate code for some of my projects, but at the same time I realize that attrs has a backwards-compatible guarantee and I don't want to break that. I'm just wondering what's the best thing to do here.
How about – and I suspect that's how it's handled elsewhere – we bake the `kw_only`-ness into a class's `Attribute` when they're created?

In your example `a` would become `kw_only=False` because that's what it is. Subclasses wouldn't know *how* it became what it is.
Right, that's how I would change it as well. I actually had a patch locally that looks like this — a very simple change:

```diff
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 4d1afe3..6faf525 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -109,7 +109,7 @@ def attrib(
     type=None,
     converter=None,
     factory=None,
-    kw_only=False,
+    kw_only=None,
     eq=None,
     order=None,
     on_setattr=None,
@@ -561,9 +561,10 @@ def _transform_attrs(
             cls, {a.name for a in own_attrs}
         )

-    if kw_only:
-        own_attrs = [a.evolve(kw_only=True) for a in own_attrs]
-        base_attrs = [a.evolve(kw_only=True) for a in base_attrs]
+    own_attrs = [
+        a.evolve(kw_only=kw_only) if a.kw_only is None else a
+        for a in own_attrs
+    ]

     attrs = base_attrs + own_attrs
```

To reiterate, I think it's totally possible (and quite easy) to allow such behavior, and I think such behavior is more intuitive than the current state. However, as my previous example, the change would be backwards-incompatible in certain cases, and I was looking for guidance on how to handle that.
Sorry for being obtuse, but how is your example backwards-incompatible? If you bake your `Attribute`s, `a` shouldn't change unless you re-define it?
No worries! Sorry for not explaining this properly. You're right that in my example, `a.kw_only` doesn't change unless being redefined. However, that is _not_ what's happening in the current implementation:

https://github.com/python-attrs/attrs/blob/c860e9dd2548e634c83f8e425ced3aede15d316a/src/attr/_make.py#L542-L544

In the current implementation, if a subclass sets `kw_only=True` on the class, `kw_only` is overridden for all attributes, whether it's defined in this class or a base class. This is also the example that I gave above, where the behavior for `Derived1` would be different were we to make this change. I do think it's a change for the better, but I'd just want to make it clear that this would be backwards-incompatible.

Let me know if you have questions, or maybe if I'm missing something!
Sorry for the delay, I had to get structlog out of the door and I'm bad at deep multi-tasking. 🙈

I think I'm starting to sense what you consider breaking here: that if someone set `kw_only=False`, it's currently ignored but would trigger then.

Of course there's Hyrum's law, but `kw_only=False` is currently meaningless and shouldn't be set by anyone on attribute-level.

We had 100 million downloads per month less back then, but IIRC, we've always did the same move in this case: switch defaults to `None` and let them still mean what the old default was. Then add explicit meaning.

---

Have you tried your changes (haven't looked too closely at them tho) and checked if our test suite still passes? 🤔
No worries!

> Have you tried your changes (haven't looked too closely at them tho) and checked if our test suite still passes? 🤔

So the following is the patch I applied on top of the current master:

```diff
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index 7aff65c..ea2ad21 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -174,7 +174,7 @@ def attrib(
     type: None = ...,
     converter: None = ...,
     factory: None = ...,
-    kw_only: bool = ...,
+    kw_only: Optional[bool] = ...,
     eq: Optional[_EqOrderType] = ...,
     order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
@@ -195,7 +195,7 @@ def attrib(
     type: Optional[Type[_T]] = ...,
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
-    kw_only: bool = ...,
+    kw_only: Optional[bool] = ...,
     eq: Optional[_EqOrderType] = ...,
     order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
@@ -215,7 +215,7 @@ def attrib(
     type: Optional[Type[_T]] = ...,
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
-    kw_only: bool = ...,
+    kw_only: Optional[bool] = ...,
     eq: Optional[_EqOrderType] = ...,
     order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
@@ -235,7 +235,7 @@ def attrib(
     type: object = ...,
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
-    kw_only: bool = ...,
+    kw_only: Optional[bool] = ...,
     eq: Optional[_EqOrderType] = ...,
     order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
@@ -252,7 +252,7 @@ def field(
     metadata: Optional[Mapping[Any, Any]] = ...,
     converter: None = ...,
     factory: None = ...,
-    kw_only: bool = ...,
+    kw_only: Optional[bool] = ...,
     eq: Optional[bool] = ...,
     order: Optional[bool] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
@@ -272,7 +272,7 @@ def field(
     metadata: Optional[Mapping[Any, Any]] = ...,
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
-    kw_only: bool = ...,
+    kw_only: Optional[bool] = ...,
     eq: Optional[_EqOrderType] = ...,
     order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
@@ -291,7 +291,7 @@ def field(
     metadata: Optional[Mapping[Any, Any]] = ...,
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
-    kw_only: bool = ...,
+    kw_only: Optional[bool] = ...,
     eq: Optional[_EqOrderType] = ...,
     order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
@@ -310,7 +310,7 @@ def field(
     metadata: Optional[Mapping[Any, Any]] = ...,
     converter: Optional[_ConverterType] = ...,
     factory: Optional[Callable[[], _T]] = ...,
-    kw_only: bool = ...,
+    kw_only: Optional[bool] = ...,
     eq: Optional[_EqOrderType] = ...,
     order: Optional[_EqOrderType] = ...,
     on_setattr: Optional[_OnSetAttrArgType] = ...,
diff --git a/src/attr/_make.py b/src/attr/_make.py
index 4e6846a..6ee2914 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -97,7 +97,7 @@ def attrib(
     type=None,
     converter=None,
     factory=None,
-    kw_only=False,
+    kw_only=None,
     eq=None,
     order=None,
     on_setattr=None,
@@ -545,9 +545,10 @@ def _transform_attrs(
             cls, {a.name for a in own_attrs}
         )
 
-    if kw_only:
-        own_attrs = [a.evolve(kw_only=True) for a in own_attrs]
-        base_attrs = [a.evolve(kw_only=True) for a in base_attrs]
+    own_attrs = [
+        a.evolve(kw_only=kw_only) if a.kw_only is None else a
+        for a in own_attrs
+    ]
 
     attrs = base_attrs + own_attrs
 
diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py
index 79e8a44..7160e9c 100644
--- a/src/attr/_next_gen.py
+++ b/src/attr/_next_gen.py
@@ -165,7 +165,7 @@ def field(
     metadata=None,
     converter=None,
     factory=None,
-    kw_only=False,
+    kw_only=None,
     eq=None,
     order=None,
     on_setattr=None,
```

All but one test pass. The failing test is `tests/test_make.py::TestTransformAttrs::test_kw_only`, which tests the behavior of `kw_only`, and it's kind of expected that it fails.

I'm actually not sure what's the better behavior, with respect to base classes. If a base class `A` sets `kw_only=False` and a subclass `B` sets `kw_only=True`, and all of their attributes have `kw_only=None` (defaulting to the class's setting), what should the behavior be? I can think of three different options:

- A class's `kw_only` setting only affects its own attributes. In this case, `B`'s own attributes will have `kw_only=True`, while the inherited attributes from `A` will still have `kw_only=False`.
- A class's `kw_only` setting affects its own & all of its base attributes that has `kw_only=None`.
- To be even fancier, we can allow `kw_only=None` on classes. Then a class's `kw_only` setting (if not None) will affect its own & all of its base attributes whose class sets `kw_only=None`.

What I implemented is option 1, just because it's easiest to implement. Of the other two options, option 2 makes more sense to me, while option 3 sounds unnecessarily complicated. Curious to know what you think.

> I think I'm starting to sense what you consider breaking here: that if someone set kw_only=False, it's currently ignored but would trigger then.
>
> Of course there's Hyrum's law, but kw_only=False is currently meaningless and shouldn't be set by anyone on attribute-level.
>
> We had 100 million downloads per month less back then, but IIRC, we've always did the same move in this case: switch defaults to None and let them still mean what the old default was. Then add explicit meaning.

So I think that the only case where this would be backwards-incompatible is if someone has a `kw_only=True` class with an attribute that explicitly sets `kw_only=False`. Since both are explicitly set, I'm not sure switching defaults here would help. I personally don't care too much about this use case, but this does count as backwards-incompatible and I'm afraid that I might break random code out there. But if you're not too worried about this, I can move forward with a PR.
Very good idea, any news?
I was surprised `kw_only` on an attribute is ignored if used on the class.
Pyright show no errors but attrs raises TypeError.

BTW, Label `dataclass` can be added, it's supported there:

```python
@dc.dataclass(kw_only=True)
class D:
    x: int = dc.field(kw_only=False)
    y: int

D(1, y=2)
```
Hey sorry everyone that this fell to the wayside. It's literally my oldest flagged email in my GitHub folder.

Honestly, the main reason is that I'm not sure how to deal with the backwards-compatibility issue. On the one hand, it's changing behavior, on the other hand it was an accidental no-op and data classes apparently support it, so I guess that's a tie-breaker?

I think it would be OK to go ahead with this and if there's enough push-back, to introduce a class-wide toggle a la `collect_by_mro`.

Are we clear on the design? How do data classes handle the subclassing problem mentioned in https://github.com/python-attrs/attrs/issues/980#issuecomment-1333323539?

Again, I'm sorry.
Hey @hynek, thanks for coming back to this issue! And please don't worry, I have totally forgotten about this issue until I saw the notification 😛

For backwards compatibility, I'm not sure if people are accidentally depending on the old behavior. I hope not but I guess with such a large user base there has to be. I personally think it makes sense to make the new behavior the default going forward, with a `collect_by_mro`-like flag as an opt-out mechanism if necessary, but I'll defer to your judgment.

Regarding the design, I think it makes sense to be consistent with `dataclass` behavior, which corresponds to option 1 in the comment above --- the class-level `kw_only=True` only applies to fields defined in the class, see example below:
```python
from dataclasses import dataclass, field

@dataclass(kw_only=False)
class A:
    a_1: int
    a_2: int
    a_override: int
    a_false: int = field(kw_only=False)
    a_true: int = field(kw_only=True)


@dataclass(kw_only=True)
class B(A):
    b_1: int
    b_2: int
    a_override: int
    b_false: int = field(kw_only=False)
    b_true: int = field(kw_only=True)


A.__init__??
# Signature:
# A.__init__(
#     self,
#     a_1: int,
#     a_2: int,
#     a_override: int,
#     a_false: int,
#     *,
#     a_true: int,
# ) -> None


B.__init__??
# Signature:
# B.__init__(
#     self,
#     a_1: int,
#     a_2: int,
#     a_false: int,
#     b_false: int,
#     *,
#     a_override: int,
#     a_true: int,
#     b_1: int,
#     b_2: int,
#     b_true: int,
# ) -> None
```

IMO this is the simplest to implement, and also easy to understand, but perhaps not the most intuitive, as it's subtly different from settings like `hash`/`eq` which apply to parent class fields as well (unless explicitly opted out). But as long as we could explain this clearly in the docs, it should be okay.

I'd be happy to put up a pull request once we settle on these details!
SGTM.

As for bw: how about we add a global switch, but leave it on by default in `define()` et al, and off in `attr.s()` / `attrs()`?

Worst case we can do a quick follow-up release that only switches the default off.
I put up a draft PR with your suggestion of back-compat. I'm not sure I completely understood what you meant by "global switch"; I added a flag to `attrs.define`/`attr.s` but with differentt defaults, so `attrs.define` has the new behavior and `attr.s` the old one by default. Let me know if this makes sense, and also open to suggestions on naming.

Code and test changes should be ready; docs still need to be written.



Patch:
diff --git a/changelog.d/1457.breaking.md b/changelog.d/1457.breaking.md
new file mode 100644
index 000000000..085bcb0e8
--- /dev/null
+++ b/changelog.d/1457.breaking.md
@@ -0,0 +1,28 @@
+Class-level `kw_only=True` behavior is now consistent with `dataclasses`.
+
+Previously, a class that sets `kw_only=True` makes all attributes keyword-only,
+including those from base classes.  If an attribute sets `kw_only=False`, that
+setting is ignored, and it is still made keyword-only.
+
+Now, only the attributes defined in that class that doesn't explicitly set
+`kw_only=False` are made keyword-only.
+
+This shouldn't be a problem for most users, unless you have a pattern like this:
+
+```python
+@attrs.define(kw_only=True)
+class Base:
+    a: int
+    b: int = attrs.field(default=1, kw_only=False)
+
+@attrs.define
+class Subclass(Base):
+    c: int
+```
+
+Here, we have a `kw_only=True` attrs class (`Base`) with an attribute that sets
+`kw_only=False` and has a default (`Base.b`), and then create a subclass (`Subclass`)
+with required arguments (`Subclass.c`).  Previously this would work, since it
+would make `Base.b` keyword-only, but now this fails since `Base.b` is positional, and
+we have a required positional argument (`Subclass.c`) following another argument with
+defaults.
diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi
index 133e50105..8d78fa19a 100644
--- a/src/attr/__init__.pyi
+++ b/src/attr/__init__.pyi
@@ -176,7 +176,7 @@ def attrib(
     type: None = ...,
     converter: None = ...,
     factory: None = ...,
-    kw_only: bool = ...,
+    kw_only: bool | None = ...,
     eq: _EqOrderType | None = ...,
     order: _EqOrderType | None = ...,
     on_setattr: _OnSetAttrArgType | None = ...,
@@ -200,7 +200,7 @@ def attrib(
     | tuple[_ConverterType]
     | None = ...,
     factory: Callable[[], _T] | None = ...,
-    kw_only: bool = ...,
+    kw_only: bool | None = ...,
     eq: _EqOrderType | None = ...,
     order: _EqOrderType | None = ...,
     on_setattr: _OnSetAttrArgType | None = ...,
@@ -223,7 +223,7 @@ def attrib(
     | tuple[_ConverterType]
     | None = ...,
     factory: Callable[[], _T] | None = ...,
-    kw_only: bool = ...,
+    kw_only: bool | None = ...,
     eq: _EqOrderType | None = ...,
     order: _EqOrderType | None = ...,
     on_setattr: _OnSetAttrArgType | None = ...,
@@ -246,7 +246,7 @@ def attrib(
     | tuple[_ConverterType]
     | None = ...,
     factory: Callable[[], _T] | None = ...,
-    kw_only: bool = ...,
+    kw_only: bool | None = ...,
     eq: _EqOrderType | None = ...,
     order: _EqOrderType | None = ...,
     on_setattr: _OnSetAttrArgType | None = ...,
diff --git a/src/attr/_make.py b/src/attr/_make.py
index b24c8d0d7..eb1cda9bb 100644
--- a/src/attr/_make.py
+++ b/src/attr/_make.py
@@ -114,7 +114,7 @@ def attrib(
     type=None,
     converter=None,
     factory=None,
-    kw_only=False,
+    kw_only=None,
     eq=None,
     order=None,
     on_setattr=None,
@@ -157,6 +157,9 @@ def attrib(
        *eq*, *order*, and *cmp* also accept a custom callable
     .. versionchanged:: 21.1.0 *cmp* undeprecated
     .. versionadded:: 22.2.0 *alias*
+    .. versionchanged:: 25.4.0
+       *kw_only* can now be None, and its default is also changed from False to
+       None.
     """
     eq, eq_key, order, order_key = _determine_attrib_eq_order(
         cmp, eq, order, True
@@ -374,7 +377,13 @@ def _collect_base_attrs_broken(cls, taken_attr_names):
 
 
 def _transform_attrs(
-    cls, these, auto_attribs, kw_only, collect_by_mro, field_transformer
+    cls,
+    these,
+    auto_attribs,
+    kw_only,
+    force_kw_only,
+    collect_by_mro,
+    field_transformer,
 ) -> _Attributes:
     """
     Transform all `_CountingAttr`s on a class into `Attribute`s.
@@ -430,7 +439,8 @@ def _transform_attrs(
 
     fca = Attribute.from_counting_attr
     own_attrs = [
-        fca(attr_name, ca, anns.get(attr_name)) for attr_name, ca in ca_list
+        fca(attr_name, ca, kw_only, anns.get(attr_name))
+        for attr_name, ca in ca_list
     ]
 
     if collect_by_mro:
@@ -442,7 +452,7 @@ def _transform_attrs(
             cls, {a.name for a in own_attrs}
         )
 
-    if kw_only:
+    if kw_only and force_kw_only:
         own_attrs = [a.evolve(kw_only=True) for a in own_attrs]
         base_attrs = [a.evolve(kw_only=True) for a in base_attrs]
 
@@ -669,6 +679,7 @@ def __init__(
         getstate_setstate,
         auto_attribs,
         kw_only,
+        force_kw_only,
         cache_hash,
         is_exc,
         collect_by_mro,
@@ -681,6 +692,7 @@ def __init__(
             these,
             auto_attribs,
             kw_only,
+            force_kw_only,
             collect_by_mro,
             field_transformer,
         )
@@ -1352,6 +1364,7 @@ def attrs(
     field_transformer=None,
     match_args=True,
     unsafe_hash=None,
+    force_kw_only=True,
 ):
     r"""
     A class decorator that adds :term:`dunder methods` according to the
@@ -1418,6 +1431,10 @@ def attrs(
        If a class has an *inherited* classmethod called
        ``__attrs_init_subclass__``, it is executed after the class is created.
     .. deprecated:: 24.1.0 *hash* is deprecated in favor of *unsafe_hash*.
+    .. versionchanged:: 25.4.0
+       *kw_only* now only applies to attributes defined in the current class,
+       and respects attribute-level ``kw_only=False`` settings.
+    .. versionadded:: 25.4.0 *force_kw_only*
     """
     if repr_ns is not None:
         import warnings
@@ -1464,6 +1481,7 @@ def wrap(cls):
             ),
             auto_attribs,
             kw_only,
+            force_kw_only,
             cache_hash,
             is_exc,
             collect_by_mro,
@@ -2516,7 +2534,11 @@ def __setattr__(self, name, value):
         raise FrozenInstanceError
 
     @classmethod
-    def from_counting_attr(cls, name: str, ca: _CountingAttr, type=None):
+    def from_counting_attr(
+        cls, name: str, ca: _CountingAttr, kw_only: bool, type=None
+    ):
+        # The 'kw_only' argument is the class-level setting, and is used if the
+        # attribute itself does not explicitly set 'kw_only'.
         # type holds the annotated value. deal with conflicts:
         if type is None:
             type = ca.type
@@ -2535,7 +2557,7 @@ def from_counting_attr(cls, name: str, ca: _CountingAttr, type=None):
             ca.metadata,
             type,
             ca.converter,
-            ca.kw_only,
+            kw_only if ca.kw_only is None else ca.kw_only,
             ca.eq,
             ca.eq_key,
             ca.order,
diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py
index 4f4b23e5e..e5fc732a1 100644
--- a/src/attr/_next_gen.py
+++ b/src/attr/_next_gen.py
@@ -43,6 +43,7 @@ def define(
     on_setattr=None,
     field_transformer=None,
     match_args=True,
+    force_kw_only=False,
 ):
     r"""
     A class decorator that adds :term:`dunder methods` according to
@@ -214,8 +215,12 @@ def define(
                 5. Subclasses of a frozen class are frozen too.
 
         kw_only (bool):
-            Make all attributes keyword-only in the generated ``__init__`` (if
-            *init* is False, this parameter is ignored).
+            Make attributes keyword-only in the generated ``__init__`` (if
+            *init* is False, this parameter is ignored).  Attributes that
+            explicitly set ``kw_only=False`` are not affected; base class
+            attributes are also not affected.
+
+            Also see *force_kw_only*.
 
         weakref_slot (bool):
             Make instances weak-referenceable.  This has no effect unless
@@ -244,6 +249,15 @@ def define(
             See also `issue #428
             <https://github.com/python-attrs/attrs/issues/428>`_.
 
+        force_kw_only (bool):
+            A back-compat flag for restoring pre-25.4.0 behavior.  If True and
+            ``kw_only=True``, all attributes are made keyword-only, including
+            base class attributes, and those set to ``kw_only=False`` at the
+            attribute level.  Defaults to False.
+
+            See also `issue #980
+            <https://github.com/python-attrs/attrs/issues/980>`_.
+
         getstate_setstate (bool | None):
             .. note::
 
@@ -319,6 +333,11 @@ def define(
     .. versionadded:: 24.3.0
        Unless already present, a ``__replace__`` method is automatically
        created for `copy.replace` (Python 3.13+ only).
+    .. versionchanged:: 25.4.0
+       *kw_only* now only applies to attributes defined in the current class,
+       and respects attribute-level ``kw_only=False`` settings.
+    .. versionadded:: 25.4.0
+       Added *force_kw_only* to go back to the previous *kw_only* behavior.
 
     .. note::
 
@@ -337,6 +356,7 @@ def define(
         - *auto_exc=True*
         - *auto_detect=True*
         - *order=False*
+        - *force_kw_only=False*
         - Some options that were only relevant on Python 2 or were kept around
           for backwards-compatibility have been removed.
 
@@ -366,6 +386,7 @@ def do_it(cls, auto_attribs):
             on_setattr=on_setattr,
             field_transformer=field_transformer,
             match_args=match_args,
+            force_kw_only=force_kw_only,
         )
 
     def wrap(cls):
@@ -424,7 +445,7 @@ def field(
     type=None,
     converter=None,
     factory=None,
-    kw_only=False,
+    kw_only=None,
     eq=None,
     order=None,
     on_setattr=None,
@@ -550,9 +571,10 @@ def field(
             itself. You can use it as part of your own code or for `static type
             checking <types>`.
 
-        kw_only (bool):
+        kw_only (bool | None):
             Make this attribute keyword-only in the generated ``__init__`` (if
-            ``init`` is False, this parameter is ignored).
+            *init* is False, this parameter is ignored).  If None (default),
+            mirror the setting from `attrs.define`.
 
         on_setattr (~typing.Callable | list[~typing.Callable] | None | ~typing.Literal[attrs.setters.NO_OP]):
             Allows to overwrite the *on_setattr* setting from `attr.s`. If left
@@ -572,6 +594,9 @@ def field(
     .. versionadded:: 23.1.0
        The *type* parameter has been re-added; mostly for `attrs.make_class`.
        Please note that type checkers ignore this metadata.
+    .. versionchanged:: 25.4.0
+       *kw_only* can now be None, and its default is also changed from False to
+       None.
 
     .. seealso::
 
diff --git a/src/attrs/__init__.pyi b/src/attrs/__init__.pyi
index 648fa7a34..128c6a79f 100644
--- a/src/attrs/__init__.pyi
+++ b/src/attrs/__init__.pyi
@@ -77,7 +77,7 @@ def field(
     metadata: Mapping[Any, Any] | None = ...,
     converter: None = ...,
     factory: None = ...,
-    kw_only: bool = ...,
+    kw_only: bool | None = ...,
     eq: bool | None = ...,
     order: bool | None = ...,
     on_setattr: _OnSetAttrArgType | None = ...,
@@ -101,7 +101,7 @@ def field(
     | tuple[_ConverterType]
     | None = ...,
     factory: Callable[[], _T] | None = ...,
-    kw_only: bool = ...,
+    kw_only: bool | None = ...,
     eq: _EqOrderType | None = ...,
     order: _EqOrderType | None = ...,
     on_setattr: _OnSetAttrArgType | None = ...,
@@ -124,7 +124,7 @@ def field(
     | tuple[_ConverterType]
     | None = ...,
     factory: Callable[[], _T] | None = ...,
-    kw_only: bool = ...,
+    kw_only: bool | None = ...,
     eq: _EqOrderType | None = ...,
     order: _EqOrderType | None = ...,
     on_setattr: _OnSetAttrArgType | None = ...,
@@ -147,7 +147,7 @@ def field(
     | tuple[_ConverterType]
     | None = ...,
     factory: Callable[[], _T] | None = ...,
-    kw_only: bool = ...,
+    kw_only: bool | None = ...,
     eq: _EqOrderType | None = ...,
     order: _EqOrderType | None = ...,
     on_setattr: _OnSetAttrArgType | None = ...,


Test patch:
diff --git a/tests/test_make.py b/tests/test_make.py
index 8f4846a78..6e35c0384 100644
--- a/tests/test_make.py
+++ b/tests/test_make.py
@@ -55,7 +55,9 @@
 from .utils import simple_attr
 
 
-attrs_st = simple_attrs.map(lambda c: Attribute.from_counting_attr("name", c))
+attrs_st = simple_attrs.map(
+    lambda c: Attribute.from_counting_attr("name", c, False)
+)
 
 
 @pytest.fixture(name="with_and_without_validation", params=[True, False])
@@ -179,7 +181,7 @@ def test_no_modifications(self):
         Does not attach __attrs_attrs__ to the class.
         """
         C = make_tc()
-        _transform_attrs(C, None, False, False, True, None)
+        _transform_attrs(C, None, False, False, False, True, None)
 
         assert None is getattr(C, "__attrs_attrs__", None)
 
@@ -188,7 +190,9 @@ def test_normal(self):
         Transforms every `_CountingAttr` and leaves others (a) be.
         """
         C = make_tc()
-        attrs, _, _ = _transform_attrs(C, None, False, False, True, None)
+        attrs, _, _ = _transform_attrs(
+            C, None, False, False, False, True, None
+        )
 
         assert ["z", "y", "x"] == [a.name for a in attrs]
 
@@ -202,7 +206,7 @@ class C:
             pass
 
         assert _Attributes((), [], {}) == _transform_attrs(
-            C, None, False, False, True, None
+            C, None, False, False, False, True, None
         )
 
     def test_transforms_to_attribute(self):
@@ -211,7 +215,7 @@ def test_transforms_to_attribute(self):
         """
         C = make_tc()
         attrs, base_attrs, _ = _transform_attrs(
-            C, None, False, False, True, None
+            C, None, False, False, False, True, None
         )
 
         assert [] == base_attrs
@@ -229,7 +233,7 @@ class C:
             y = attr.ib()
 
         with pytest.raises(ValueError) as e:
-            _transform_attrs(C, None, False, False, True, None)
+            _transform_attrs(C, None, False, False, False, True, None)
         assert (
             "No mandatory attributes allowed after an attribute with a "
             "default value or factory.  Attribute in question: Attribute"
@@ -242,11 +246,8 @@ class C:
 
     def test_kw_only(self):
         """
-        Converts all attributes, including base class' attributes, if `kw_only`
-        is provided. Therefore, `kw_only` allows attributes with defaults to
-        precede mandatory attributes.
-
-        Updates in the subclass *don't* affect the base class attributes.
+        Converts all attributes in the current class if `kw_only` is provided.
+        Base class attributes are **not** affected.
         """
 
         @attr.s
@@ -261,7 +262,26 @@ class C(B):
             y = attr.ib()
 
         attrs, base_attrs, _ = _transform_attrs(
-            C, None, False, True, True, None
+            C, None, False, True, False, True, None
+        )
+
+        assert len(attrs) == 3
+        assert len(base_attrs) == 1
+
+        for a in attrs:
+            if a.name == "b":
+                assert a.kw_only is False
+            else:
+                assert a.kw_only is True
+
+        attrs, base_attrs, _ = _transform_attrs(
+            C,
+            None,
+            False,
+            True,
+            True,  # force kw-only
+            True,
+            None,
         )
 
         assert len(attrs) == 3
@@ -285,7 +305,7 @@ class C(Base):
             y = attr.ib()
 
         attrs, base_attrs, _ = _transform_attrs(
-            C, {"x": attr.ib()}, False, False, True, None
+            C, {"x": attr.ib()}, False, False, False, True, None
         )
 
         assert [] == base_attrs
@@ -1018,8 +1038,8 @@ class C:
 
     def test_keyword_only_class_level_subclassing(self):
         """
-        Subclass `kw_only` propagates to attrs inherited from the base,
-        allowing non-default following default.
+        When `force_kw_only=True`, subclass `kw_only` propagates to attrs
+        inherited from the base, allowing non-default following default.
         """
 
         @attr.s
@@ -1032,12 +1052,23 @@ class C(Base):
 
         with pytest.raises(TypeError):
             C(1)
+        with pytest.raises(TypeError):
+            C(0, y=1)
 
         c = C(x=0, y=1)
 
         assert c.x == 0
         assert c.y == 1
 
+        @attr.s(kw_only=True, force_kw_only=False)
+        class C(Base):
+            y = attr.ib()
+
+        c = C(0, y=1)
+
+        assert c.x == 0
+        assert c.y == 1
+
     def test_init_false_attribute_after_keyword_attribute(self):
         """
         A positional attribute cannot follow a `kw_only` attribute,
@@ -1094,6 +1125,144 @@ def _init_to_init(self):
         assert c.non_init_function_default == "ab"
         assert c.non_init_keyword_default == "default-by-keyword"
 
+    def test_attr_level_kw_only_and_class_level_kw_only(self):
+        """
+        If a field explicitly sets 'kw_only=False', it should not be made
+        keyword-only even if the class sets 'kw_only=True'.
+        """
+
+        @attr.s(kw_only=True)
+        class OldClassOldBehavior:
+            yes = attr.ib()
+            no = attr.ib(kw_only=False)
+
+        @attr.s(kw_only=True, force_kw_only=False)
+        class OldClassNewBehavior:
+            yes = attr.ib()
+            no = attr.ib(kw_only=False)
+
+        @attr.define(kw_only=True, force_kw_only=True)
+        class NewClassOldBehavior:
+            yes = attr.field()
+            no = attr.field(kw_only=False)
+
+        @attr.define(kw_only=True)
+        class NewClassNewBehavior:
+            yes = attr.field()
+            no = attr.field(kw_only=False)
+
+        for cls in [OldClassNewBehavior, NewClassNewBehavior]:
+            fs = fields_dict(cls)
+            assert fs["yes"].kw_only is True
+            assert fs["no"].kw_only is False
+
+        for cls in [OldClassOldBehavior, NewClassOldBehavior]:
+            fs = fields_dict(cls)
+
+            assert fs["yes"].kw_only is True
+            assert fs["no"].kw_only is True
+
+    def test_kw_only_inheritance(self):
+        """
+        Comprehensive test about how `kw_only` works when there's multiple
+        levels of inheritance with different `kw_only` settings.
+        """
+
+        @attr.define()
+        class A:
+            a = attr.field()
+            a_t = attr.field(kw_only=True)
+            a_f = attr.field(kw_only=False)
+
+        @attr.define(kw_only=True)
+        class B(A):
+            b = attr.field()
+            b_t = attr.field(kw_only=True)
+            b_f = attr.field(kw_only=False)
+
+        @attr.define(kw_only=False)
+        class C(B):
+            c = attr.field()
+            c_t = attr.field(kw_only=True)
+            c_f = attr.field(kw_only=False)
+
+        fs = fields_dict(A)
+
+        assert fs["a"].kw_only is False
+        assert fs["a_t"].kw_only is True
+        assert fs["a_f"].kw_only is False
+
+        fs = fields_dict(B)
+
+        assert fs["a"].kw_only is False
+        assert fs["a_t"].kw_only is True
+        assert fs["a_f"].kw_only is False
+        assert fs["b"].kw_only is True
+        assert fs["b_t"].kw_only is True
+        assert fs["b_f"].kw_only is False
+
+        fs = fields_dict(C)
+
+        assert fs["a"].kw_only is False
+        assert fs["a_t"].kw_only is True
+        assert fs["a_f"].kw_only is False
+        assert fs["b"].kw_only is True
+        assert fs["b_t"].kw_only is True
+        assert fs["b_f"].kw_only is False
+        assert fs["c"].kw_only is False
+        assert fs["c_t"].kw_only is True
+        assert fs["c_f"].kw_only is False
+
+    def test_kw_only_inheritance_force_kw_only(self):
+        """
+        Similar to above, but when `force_kw_only` (pre-25.3.0 behavior).
+        """
+
+        @attr.define(force_kw_only=True)
+        class A:
+            a = attr.field()
+            a_t = attr.field(kw_only=True)
+            a_f = attr.field(kw_only=False)
+
+        @attr.define(kw_only=True, force_kw_only=True)
+        class B(A):
+            b = attr.field()
+            b_t = attr.field(kw_only=True)
+            b_f = attr.field(kw_only=False)
+
+        @attr.define(kw_only=False, force_kw_only=True)
+        class C(B):
+            c = attr.field()
+            c_t = attr.field(kw_only=True)
+            c_f = attr.field(kw_only=False)
+
+        fs = fields_dict(A)
+
+        assert fs["a"].kw_only is False
+        assert fs["a_t"].kw_only is True
+        assert fs["a_f"].kw_only is False
+
+        fs = fields_dict(B)
+
+        assert fs["a"].kw_only is True
+        assert fs["a_t"].kw_only is True
+        assert fs["a_f"].kw_only is True
+        assert fs["b"].kw_only is True
+        assert fs["b_t"].kw_only is True
+        assert fs["b_f"].kw_only is True
+
+        fs = fields_dict(C)
+
+        assert fs["a"].kw_only is False
+        assert fs["a_t"].kw_only is True
+        assert fs["a_f"].kw_only is False
+        assert fs["b"].kw_only is True
+        assert fs["b_t"].kw_only is True
+        assert fs["b_f"].kw_only is True
+        assert fs["c"].kw_only is False
+        assert fs["c_t"].kw_only is True
+        assert fs["c_f"].kw_only is False
+
 
 @attr.s
 class GC:
@@ -1767,6 +1936,7 @@ class C:
             False,
             False,
             False,
+            False,
             True,
             None,
             False,
@@ -1794,6 +1964,7 @@ class C:
             False,
             False,
             False,
+            False,
             True,
             None,
             False,
@@ -1886,6 +2057,7 @@ def our_hasattr(obj, name, /) -> bool:
             auto_attribs=False,
             is_exc=False,
             kw_only=False,
+            force_kw_only=False,
             cache_hash=False,
             collect_by_mro=True,
             on_setattr=None,