Repository: scrapy/scrapy
Pull request: Support for dataclass and attrs items
Fixes #3761, closes #2807

It seems to me like there would be a limitation in the way we could use dataclassess with ItemLoaders. Given the fact that all fields are supposed to be provided (except for those with default values, or unless a custom constuctor is provided) and that loaders try to _append_ values, I think only dataclasses defined as the following would work:
```python
from dataclasses import dataclass, field
from scrapy.loader import ItemLoader

@dataclass
class InventoryItem:
    name: list = field(default_factory=list)
    price: list = field(default_factory=list)
    stock: list = field(default_factory=list)

lo = ItemLoader(item=InventoryItem())
lo.add_value('name', 'foo bar')
print(lo.load_item())  # prints "InventoryItem(name=['foo bar'], price=[], stock=[])"
```

Thanks in advance for your feedback!

---

**Update**

Checklist:

- [x] Scraper class changes
- [x] Exporters support
- [x] Item loader support
- [x] Parse command
- [x] Shell command
- [x] Contracts
- [x] Docs
- [x] Tests

This patch works with the built-in [`dataclassess` module](https://docs.python.org/3.7/library/dataclasses.html) in Python 3.7+, and with the [`dataclassess` backport](https://pypi.org/project/dataclasses/) in Python 3.6.

**Update 2**
Checklist for the new implementation based on the `ItemAdapter` class
- [x] ItemAdapter class
- [x] Refactor existing components to use ItemAdapter class
- [x] ItemAdapter tests
- [x] docs (WIP)


**Update 3:**
Supporting [`python-attrs`](https://www.attrs.org) now too. Implementation is based on https://github.com/scrapy/itemadapter.

Problem statement:
Items as Dataclasses
Is there any plan to support dataclasses as `yield`-able results from crawlers (and possibly `meta` in `Request`/`Result`)?

It would be a very welcome addition for us, mostly for typing reasons, IDE completion, additional logic.
And it should also be simple to implement (just call `dataclasses.asdict()`)?

Hints from issue discussion:
@Gallaecio I would like to implement this, any discussion required before proceeding further?
@gsb-eng I think you can go ahead.
+1 to add dataclasses and attrs support (in separate pull requests), I think it'd be awesome to have support for them.
https://github.com/scrapy/scrapy/pull/2807 is related, though not the same.

Commit message: Minor documentation improvements (#6)

Patch to review:
diff --git a/docs/faq.rst b/docs/faq.rst
index 75a0f4864ff..df28f3c3cab 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -360,6 +360,62 @@ Yes, by setting :setting:`DNS_RESOLVER` to ``scrapy.resolver.CachingHostnameReso
 Note that by doing so, you lose the ability to set a specific timeout for DNS requests
 (the value of the :setting:`DNS_TIMEOUT` setting is ignored).
 
+.. _faq-dataclass-items:
+
+Can I use dataclasses as items?
+-------------------------------
+
+Support for :class:`dataclasses.dataclass` objects as items was added in version 2.1.
+This works natively in Python 3.7+, or using the `dataclasses backport`_ in Python 3.6.
+
+Most of the examples in this documentation assume you are using either :class:`dict`
+or :class:`~scrapy.item.Item` objects, and access their values in a dictionary-like
+manner. However, ``dataclass`` objects expose their values trough attributes instead,
+which means you might need to update your existing components
+(such as :ref:`item pipelines <topics-item-pipeline>`,
+:ref:`signal handlers <topics-signals>` handlers or
+:ref:`spider middlewares <topics-spider-middleware>`) to make them work correctly.
+
+Alternatively, you can use the
+:func:`~scrapy.utils.decorators.subscriptable_dataclass` decorator:
+
+.. autofunction:: scrapy.utils.decorators.subscriptable_dataclass
+
+It adds the appropriate methods in order to make ``dataclass`` objects
+capable of being accessed like dictionaries. This decorator is also useful when working with
+third-party components which deal with items and haven't been yet adapted to
+support dataclass-based items.
+
+.. invisible-code-block: python
+
+  import sys
+
+.. skip: start if(sys.version_info < (3, 6), reason="python 3.6+ only")
+
+>>> from dataclasses import dataclass
+>>> from scrapy.utils.decorators import subscriptable_dataclass
+>>> @subscriptable_dataclass
+... @dataclass
+... class InventoryItem:
+...     name: str
+...     price: int
+...
+>>> d = InventoryItem(name="foobar", price=10)
+>>> d["name"]
+'foobar'
+>>> d["price"] = 5
+>>> d
+InventoryItem(name='foobar', price=5)
+
+.. skip: end
+
+In addition, two helper functions are available to interact with items without
+having to check their base class:
+
+.. autofunction:: scrapy.utils.datatypes.get_item_field
+
+.. autofunction:: scrapy.utils.datatypes.set_item_field
+
 
 .. _faq-specific-reactor:
 
@@ -376,3 +432,4 @@ different reactor is possible by using the :setting:`TWISTED_REACTOR` setting.
 .. _LIFO: https://en.wikipedia.org/wiki/Stack_(abstract_data_type)
 .. _DFO order: https://en.wikipedia.org/wiki/Depth-first_search
 .. _BFO order: https://en.wikipedia.org/wiki/Breadth-first_search
+.. _dataclasses backport: https://pypi.org/project/dataclasses/
diff --git a/docs/topics/item-pipeline.rst b/docs/topics/item-pipeline.rst
index 98e2506e597..5832fd9aba5 100644
--- a/docs/topics/item-pipeline.rst
+++ b/docs/topics/item-pipeline.rst
@@ -34,12 +34,15 @@ Each item pipeline component is a Python class that must implement the following
    :exc:`~scrapy.exceptions.DropItem` exception. Dropped items are no longer
    processed by further pipeline components.
 
-   :param item: the item scraped
-   :type item: :class:`~scrapy.item.Item` object or a dict
+   :param item: the scraped item
+   :type item: :class:`~scrapy.item.Item`, :class:`dict`, :func:`dataclasses.dataclass`
 
    :param spider: the spider which scraped the item
    :type spider: :class:`~scrapy.spiders.Spider` object
 
+  .. note:: Please see :ref:`this FAQ entry <faq-dataclass-items>`
+            if you are using :func:`dataclasses.dataclass` objects as items
+
 Additionally, they may also implement the following methods:
 
 .. method:: open_spider(self, spider)
diff --git a/docs/topics/loaders.rst b/docs/topics/loaders.rst
index 5f75ccbff08..b92c8964e27 100644
--- a/docs/topics/loaders.rst
+++ b/docs/topics/loaders.rst
@@ -9,9 +9,10 @@ Item Loaders
 
 Item Loaders provide a convenient mechanism for populating scraped :ref:`Items
 <topics-items>`. Even though Items can be populated using their own
-dictionary-like API, Item Loaders provide a much more convenient API for
-populating them from a scraping process, by automating some common tasks like
-parsing the raw extracted data before assigning it.
+dictionary-like API (or by setting their attributes in the case of
+:ref:`dataclass-based items <faq-dataclass-items>`), Item Loaders provide a
+much more convenient API for populating them from a scraping process, by automating
+some common tasks like parsing the raw extracted data before assigning it.
 
 In other words, :ref:`Items <topics-items>` provide the *container* of
 scraped data, while Item Loaders provide the mechanism for *populating* that
@@ -25,8 +26,8 @@ Using Item Loaders to populate items
 ====================================
 
 To use an Item Loader, you must first instantiate it. You can either
-instantiate it with a dict-like object (e.g. Item or dict) or without one, in
-which case an Item is automatically instantiated in the Item Loader ``__init__`` method
+instantiate it with a Item-like object or without one, in which case an Item
+is automatically instantiated in the Item Loader ``__init__`` method
 using the Item class specified in the :attr:`ItemLoader.default_item_class`
 attribute.
 
diff --git a/docs/topics/media-pipeline.rst b/docs/topics/media-pipeline.rst
index cd84905c56c..85c4f5cacea 100644
--- a/docs/topics/media-pipeline.rst
+++ b/docs/topics/media-pipeline.rst
@@ -246,21 +246,38 @@ Usage example
 In order to use a media pipeline first, :ref:`enable it
 <topics-media-pipeline-enabling>`.
 
-Then, if a spider returns a dict with the URLs key (``file_urls`` or
+Then, if a spider returns a :class:`dict` with the URLs key (``file_urls`` or
 ``image_urls``, for the Files or Images Pipeline respectively), the pipeline will
 put the results under respective key (``files`` or ``images``).
 
-If you prefer to use :class:`~.Item`, then define a custom item with the
+If you prefer to use a custom :class:`~.Item` class, then define it with the
 necessary fields, like in this example for Images Pipeline::
 
     import scrapy
 
     class MyItem(scrapy.Item):
-
         # ... other item fields ...
         image_urls = scrapy.Field()
         images = scrapy.Field()
 
+.. invisible-code-block: python
+
+  import sys
+
+.. skip: start if(sys.version_info < (3, 6), reason="python 3.6+ only")
+
+Or as a :ref:`dataclass-based item <faq-dataclass-items>`::
+
+    from dataclasses import dataclass
+
+    @dataclass
+    class MyDataclassItem:
+        # ... other item fields ...
+        image_urls: list
+        images: list
+
+.. skip: end
+
 If you want to use another field name for the URLs key or for the results key,
 it is also possible to override it.
 
diff --git a/docs/topics/signals.rst b/docs/topics/signals.rst
index 2def5384879..a823f1776ea 100644
--- a/docs/topics/signals.rst
+++ b/docs/topics/signals.rst
@@ -16,6 +16,9 @@ deliver the arguments that the handler receives.
 You can connect to signals (or send your own) through the
 :ref:`topics-api-signals`.
 
+.. note:: Please see :ref:`this FAQ entry <faq-dataclass-items>`
+          if you are using :func:`dataclasses.dataclass` objects as items
+
 Here is a simple example showing how you can catch signals and perform some action:
 ::
 
@@ -102,8 +105,8 @@ item_scraped
 
     This signal supports returning deferreds from their handlers.
 
-    :param item: the item scraped
-    :type item: dict or :class:`~scrapy.item.Item` object
+    :param item: the scraped item
+    :type item: :class:`~scrapy.item.Item`, :class:`dict`, :func:`dataclasses.dataclass`
 
     :param spider: the spider which scraped the item
     :type spider: :class:`~scrapy.spiders.Spider` object
@@ -123,7 +126,7 @@ item_dropped
     This signal supports returning deferreds from their handlers.
 
     :param item: the item dropped from the :ref:`topics-item-pipeline`
-    :type item: dict or :class:`~scrapy.item.Item` object
+    :type item: :class:`~scrapy.item.Item`, :class:`dict`, :func:`dataclasses.dataclass`
 
     :param spider: the spider which scraped the item
     :type spider: :class:`~scrapy.spiders.Spider` object
@@ -147,8 +150,8 @@ item_error
 
     This signal supports returning deferreds from their handlers.
 
-    :param item: the item dropped from the :ref:`topics-item-pipeline`
-    :type item: dict or :class:`~scrapy.item.Item` object
+    :param item: the item that caused the error in the :ref:`topics-item-pipeline`
+    :type item: :class:`~scrapy.item.Item`, :class:`dict`, :func:`dataclasses.dataclass`
 
     :param response: the response being processed when the exception was raised
     :type response: :class:`~scrapy.http.Response` object
diff --git a/docs/topics/spider-middleware.rst b/docs/topics/spider-middleware.rst
index 0e8210130ac..2b12408b2e5 100644
--- a/docs/topics/spider-middleware.rst
+++ b/docs/topics/spider-middleware.rst
@@ -110,12 +110,16 @@ object gives you access, for example, to the :ref:`settings <topics-settings>`.
         :type response: :class:`~scrapy.http.Response` object
 
         :param result: the result returned by the spider
-        :type result: an iterable of :class:`~scrapy.http.Request`, dict
-          or :class:`~scrapy.item.Item` objects
+        :type result: an iterable of :class:`~scrapy.http.Request`,
+          :class:`~scrapy.item.Item`,
+          :class:`dict` or :func:`dataclasses.dataclass` objects
 
         :param spider: the spider whose result is being processed
         :type spider: :class:`~scrapy.spiders.Spider` object
 
+    .. note:: Please see :ref:`this FAQ entry <faq-dataclass-items>`
+              if you are using :func:`dataclasses.dataclass` objects as items
+
 
     .. method:: process_spider_exception(response, exception, spider)
 
diff --git a/scrapy/commands/parse.py b/scrapy/commands/parse.py
index 3ef8ddcb37a..d495636f243 100644
--- a/scrapy/commands/parse.py
+++ b/scrapy/commands/parse.py
@@ -5,9 +5,9 @@
 
 from scrapy.commands import ScrapyCommand
 from scrapy.http import Request
-from scrapy.item import BaseItem
 from scrapy.utils import display
 from scrapy.utils.conf import arglist_to_dict
+from scrapy.utils.misc import is_item_like
 from scrapy.utils.spider import iterate_spider_output, spidercls_for_request
 from scrapy.exceptions import UsageError
 
@@ -117,7 +117,7 @@ def run_callback(self, response, callback, cb_kwargs=None):
         items, requests = [], []
 
         for x in iterate_spider_output(callback(response, **cb_kwargs)):
-            if isinstance(x, (BaseItem, dict)):
+            if is_item_like(x):
                 items.append(x)
             elif isinstance(x, Request):
                 requests.append(x)
diff --git a/scrapy/contracts/default.py b/scrapy/contracts/default.py
index 3002fc702fa..993eab428f1 100644
--- a/scrapy/contracts/default.py
+++ b/scrapy/contracts/default.py
@@ -1,10 +1,13 @@
 import json
 
-from scrapy.item import BaseItem
-from scrapy.http import Request
+from scrapy.contracts import Contract
 from scrapy.exceptions import ContractFail
+from scrapy.http import Request
+from scrapy.utils.misc import is_item_like
 
-from scrapy.contracts import Contract
+
+def _is_request_like(obj):
+    return isinstance(obj, Request)
 
 
 # contracts
@@ -48,11 +51,11 @@ class ReturnsContract(Contract):
     """
 
     name = 'returns'
-    objects = {
-        'request': Request,
-        'requests': Request,
-        'item': (BaseItem, dict),
-        'items': (BaseItem, dict),
+    object_type_verifiers = {
+        'request': _is_request_like,
+        'requests': _is_request_like,
+        'item': is_item_like,
+        'items': is_item_like,
     }
 
     def __init__(self, *args, **kwargs):
@@ -60,7 +63,7 @@ def __init__(self, *args, **kwargs):
 
         assert len(self.args) in [1, 2, 3]
         self.obj_name = self.args[0] or None
-        self.obj_type = self.objects[self.obj_name]
+        self.obj_type_verifier = self.object_type_verifiers[self.obj_name]
 
         try:
             self.min_bound = int(self.args[1])
@@ -75,7 +78,7 @@ def __init__(self, *args, **kwargs):
     def post_process(self, output):
         occurrences = 0
         for x in output:
-            if isinstance(x, self.obj_type):
+            if self.obj_type_verifier(x):
                 occurrences += 1
 
         assertion = (self.min_bound <= occurrences <= self.max_bound)
@@ -99,8 +102,8 @@ class ScrapesContract(Contract):
 
     def post_process(self, output):
         for x in output:
-            if isinstance(x, (BaseItem, dict)):
+            if is_item_like(x):
                 missing = [arg for arg in self.args if arg not in x]
                 if missing:
-                    raise ContractFail(
-                        "Missing fields: %s" % ", ".join(missing))
+                    missing_str = ", ".join(missing)
+                    raise ContractFail("Missing fields: %s" % missing_str)
diff --git a/scrapy/core/scraper.py b/scrapy/core/scraper.py
index 41f01501784..1aa0c5c0e41 100644
--- a/scrapy/core/scraper.py
+++ b/scrapy/core/scraper.py
@@ -4,18 +4,17 @@
 import logging
 from collections import deque
 
-from twisted.python.failure import Failure
 from twisted.internet import defer
+from twisted.python.failure import Failure
 
-from scrapy.utils.defer import defer_result, defer_succeed, parallel, iter_errback
-from scrapy.utils.spider import iterate_spider_output
-from scrapy.utils.misc import load_object, warn_on_generator_with_return_value
-from scrapy.utils.log import logformatter_adapter, failure_to_exc_info
-from scrapy.exceptions import CloseSpider, DropItem, IgnoreRequest
 from scrapy import signals
-from scrapy.http import Request, Response
-from scrapy.item import BaseItem
 from scrapy.core.spidermw import SpiderMiddlewareManager
+from scrapy.exceptions import CloseSpider, DropItem, IgnoreRequest
+from scrapy.http import Request, Response
+from scrapy.utils.defer import defer_result, defer_succeed, iter_errback, parallel
+from scrapy.utils.log import failure_to_exc_info, logformatter_adapter
+from scrapy.utils.misc import is_item_like, load_object, warn_on_generator_with_return_value
+from scrapy.utils.spider import iterate_spider_output
 
 
 logger = logging.getLogger(__name__)
@@ -187,7 +186,7 @@ def _process_spidermw_output(self, output, request, response, spider):
         """
         if isinstance(output, Request):
             self.crawler.engine.crawl(request=output, spider=spider)
-        elif isinstance(output, (BaseItem, dict)):
+        elif is_item_like(output):
             self.slot.itemproc_size += 1
             dfd = self.itemproc.process_item(output, spider)
             dfd.addBoth(self._itemproc_finished, output, response, spider)
@@ -196,7 +195,7 @@ def _process_spidermw_output(self, output, request, response, spider):
             pass
         else:
             typename = type(output).__name__
-            logger.error('Spider must return Request, BaseItem, dict or None, '
+            logger.error('Spider must return Request, BaseItem, dict, dataclass or None, '
                          'got %(typename)r in %(request)s',
                          {'request': request, 'typename': typename},
                          extra={'spider': spider})
diff --git a/scrapy/exporters.py b/scrapy/exporters.py
index 2e20a718040..02ed7bbaa22 100644
--- a/scrapy/exporters.py
+++ b/scrapy/exporters.py
@@ -4,16 +4,17 @@
 
 import csv
 import io
-import pprint
 import marshal
-import warnings
 import pickle
+import pprint
+import warnings
 from xml.sax.saxutils import XMLGenerator
 
-from scrapy.utils.serialize import ScrapyJSONEncoder
-from scrapy.utils.python import to_bytes, to_unicode, is_listlike
-from scrapy.item import BaseItem
 from scrapy.exceptions import ScrapyDeprecationWarning
+from scrapy.item import BaseItem
+from scrapy.utils.python import (dataclass_asdict, is_dataclass_instance,
+                                 is_listlike, to_bytes, to_unicode)
+from scrapy.utils.serialize import ScrapyJSONEncoder
 
 
 __all__ = ['BaseItemExporter', 'PprintItemExporter', 'PickleItemExporter',
@@ -56,6 +57,8 @@ def _get_serialized_fields(self, item, default_value=None, include_empty=None):
         """Return the fields to export as an iterable of tuples
         (name, serialized_value)
         """
+        if is_dataclass_instance(item):
+            item = dataclass_asdict(item)
         if include_empty is None:
             include_empty = self.export_empty_fields
         if self.fields_to_export is None:
diff --git a/scrapy/item.py b/scrapy/item.py
index 1d39b48b2bf..18434f80e2e 100644
--- a/scrapy/item.py
+++ b/scrapy/item.py
@@ -15,15 +15,18 @@
 
 
 class BaseItem(object_ref):
-    """Base class for all scraped items.
+    """Base class for scraped items.
 
-    In Scrapy, an object is considered an *item* if it is an instance of either
-    :class:`BaseItem` or :class:`dict`. For example, when the output of a
-    spider callback is evaluated, only instances of :class:`BaseItem` or
-    :class:`dict` are passed to :ref:`item pipelines <topics-item-pipeline>`.
+    In Scrapy, an object is considered an *item* if:
+    - it is an instance of :class:`BaseItem`, or
+    - it is an instance of :class:`dict`, or
+    - it is a dataclass object (see the *dataclasses* module in Python 3.6+)
+    For example, when the output of a spider callback is evaluated, only item
+    instances are passed to :ref:`item pipelines <topics-item-pipeline>`.
 
     If you need instances of a custom class to be considered items by Scrapy,
-    you must inherit from either :class:`BaseItem` or :class:`dict`.
+    you must either make said class inherit from :class:`BaseItem` or
+    :class:`dict`, or decorate it as a dataclass object.
 
     Unlike instances of :class:`dict`, instances of :class:`BaseItem` may be
     :ref:`tracked <topics-leaks-trackrefs>` to debug memory leaks.
diff --git a/scrapy/loader/__init__.py b/scrapy/loader/__init__.py
index 7cf67e29eea..07d4eac8b4c 100644
--- a/scrapy/loader/__init__.py
+++ b/scrapy/loader/__init__.py
@@ -11,7 +11,8 @@
 from scrapy.loader.processors import Identity
 from scrapy.selector import Selector
 from scrapy.utils.misc import arg_to_iter, extract_regex
-from scrapy.utils.python import flatten
+from scrapy.utils.datatypes import set_item_field
+from scrapy.utils.python import flatten, is_dataclass_instance, dataclass_asdict
 
 
 def unbound_method(method):
@@ -44,6 +45,8 @@ def __init__(self, item=None, selector=None, response=None, parent=None, **conte
         self._local_item = context['item'] = item
         self._local_values = defaultdict(list)
         # values from initial item
+        if is_dataclass_instance(item):
+            item = dataclass_asdict(item)
         for field_name, value in item.items():
             self._values[field_name] += arg_to_iter(value)
 
@@ -131,8 +134,7 @@ def load_item(self):
         for field_name in tuple(self._values):
             value = self.get_output_value(field_name)
             if value is not None:
-                item[field_name] = value
-
+                set_item_field(item, field_name, value)
         return item
 
     def get_output_value(self, field_name):
diff --git a/scrapy/pipelines/files.py b/scrapy/pipelines/files.py
index 9b744575512..78e1f0261bc 100644
--- a/scrapy/pipelines/files.py
+++ b/scrapy/pipelines/files.py
@@ -10,24 +10,30 @@
 import os
 import time
 from collections import defaultdict
-from email.utils import parsedate_tz, mktime_tz
+from email.utils import mktime_tz, parsedate_tz
 from ftplib import FTP
 from io import BytesIO
 from urllib.parse import urlparse
 
 from twisted.internet import defer, threads
 
+from scrapy.exceptions import IgnoreRequest, NotConfigured
+from scrapy.http import Request
 from scrapy.pipelines.media import MediaPipeline
 from scrapy.settings import Settings
-from scrapy.exceptions import NotConfigured, IgnoreRequest
-from scrapy.http import Request
-from scrapy.utils.misc import md5sum
-from scrapy.utils.log import failure_to_exc_info
-from scrapy.utils.python import to_bytes
-from scrapy.utils.request import referer_str
 from scrapy.utils.boto import is_botocore
-from scrapy.utils.datatypes import CaselessDict
+from scrapy.utils.datatypes import CaselessDict, get_item_field, set_item_field
 from scrapy.utils.ftp import ftp_store_file
+from scrapy.utils.log import failure_to_exc_info
+from scrapy.utils.misc import md5sum
+from scrapy.utils.python import is_dataclass_instance, to_bytes
+from scrapy.utils.request import referer_str
+
+
+try:
+    from dataclasses import fields as dataclass_fields
+except ImportError:
+    dataclass_fields = None
 
 
 logger = logging.getLogger(__name__)
@@ -502,7 +508,8 @@ def inc_stats(self, spider, status):
 
     ### Overridable Interface
     def get_media_requests(self, item, info):
-        return [Request(x) for x in item.get(self.files_urls_field, [])]
+        urls = get_item_field(item, self.files_urls_field, [])
+        return [Request(u) for u in urls]
 
     def file_downloaded(self, response, request, info):
         path = self.file_path(request, response=response, info=info)
@@ -513,8 +520,13 @@ def file_downloaded(self, response, request, info):
         return checksum
 
     def item_completed(self, results, item, info):
-        if isinstance(item, dict) or self.files_result_field in item.fields:
-            item[self.files_result_field] = [x for ok, x in results if ok]
+        if (
+            is_dataclass_instance(item)
+            and self.files_result_field in (f.name for f in dataclass_fields(item))
+            or isinstance(item, dict)
+            or self.files_result_field in item.fields
+        ):
+            set_item_field(item, self.files_result_field, [x for ok, x in results if ok])
         return item
 
     def file_path(self, request, response=None, info=None):
diff --git a/scrapy/pipelines/images.py b/scrapy/pipelines/images.py
index 2e646379cc4..df243f85d7f 100644
--- a/scrapy/pipelines/images.py
+++ b/scrapy/pipelines/images.py
@@ -9,13 +9,20 @@
 
 from PIL import Image
 
-from scrapy.utils.misc import md5sum
-from scrapy.utils.python import to_bytes
-from scrapy.http import Request
-from scrapy.settings import Settings
 from scrapy.exceptions import DropItem
-#TODO: from scrapy.pipelines.media import MediaPipeline
+from scrapy.http import Request
 from scrapy.pipelines.files import FileException, FilesPipeline
+# TODO: from scrapy.pipelines.media import MediaPipeline
+from scrapy.settings import Settings
+from scrapy.utils.datatypes import get_item_field, set_item_field
+from scrapy.utils.misc import md5sum
+from scrapy.utils.python import is_dataclass_instance, to_bytes
+
+
+try:
+    from dataclasses import fields as dataclass_fields
+except ImportError:
+    dataclass_fields = None
 
 
 class NoimagesDrop(DropItem):
@@ -157,11 +164,17 @@ def convert_image(self, image, size=None):
         return image, buf
 
     def get_media_requests(self, item, info):
-        return [Request(x) for x in item.get(self.images_urls_field, [])]
+        urls = get_item_field(item, self.images_urls_field, [])
+        return [Request(u) for u in urls]
 
     def item_completed(self, results, item, info):
-        if isinstance(item, dict) or self.images_result_field in item.fields:
-            item[self.images_result_field] = [x for ok, x in results if ok]
+        if (
+            is_dataclass_instance(item)
+            and self.images_result_field in (f.name for f in dataclass_fields(item))
+            or isinstance(item, dict)
+            or self.images_result_field in item.fields
+        ):
+            set_item_field(item, self.images_result_field, [x for ok, x in results if ok])
         return item
 
     def file_path(self, request, response=None, info=None):
diff --git a/scrapy/pipelines/media.py b/scrapy/pipelines/media.py
index c174addf919..8f6d3640285 100644
--- a/scrapy/pipelines/media.py
+++ b/scrapy/pipelines/media.py
@@ -1,15 +1,17 @@
 import functools
 import logging
 from collections import defaultdict
+
 from twisted.internet.defer import Deferred, DeferredList, _DefGen_Return
 from twisted.python.failure import Failure
 
 from scrapy.settings import Settings
 from scrapy.utils.datatypes import SequenceExclude
-from scrapy.utils.defer import mustbe_deferred, defer_result
-from scrapy.utils.request import request_fingerprint
-from scrapy.utils.misc import arg_to_iter
+from scrapy.utils.defer import defer_result, mustbe_deferred
 from scrapy.utils.log import failure_to_exc_info
+from scrapy.utils.misc import arg_to_iter
+from scrapy.utils.request import request_fingerprint
+
 
 logger = logging.getLogger(__name__)
 
diff --git a/scrapy/shell.py b/scrapy/shell.py
index a5e14048450..be7ff1ac668 100644
--- a/scrapy/shell.py
+++ b/scrapy/shell.py
@@ -13,21 +13,18 @@
 from scrapy.crawler import Crawler
 from scrapy.exceptions import IgnoreRequest
 from scrapy.http import Request, Response
-from scrapy.item import BaseItem
 from scrapy.settings import Settings
 from scrapy.spiders import Spider
-from scrapy.utils.console import start_python_console
+from scrapy.utils.conf import get_config
+from scrapy.utils.console import DEFAULT_PYTHON_SHELLS, start_python_console
 from scrapy.utils.datatypes import SequenceExclude
-from scrapy.utils.misc import load_object
+from scrapy.utils.misc import is_item_like, load_object
 from scrapy.utils.response import open_in_browser
-from scrapy.utils.conf import get_config
-from scrapy.utils.console import DEFAULT_PYTHON_SHELLS
 
 
 class Shell(object):
 
-    relevant_classes = (Crawler, Spider, Request, Response, BaseItem,
-                        Settings)
+    relevant_classes = (Crawler, Spider, Request, Response, Settings)
 
     def __init__(self, crawler, update_vars=None, code=None):
         self.crawler = crawler
@@ -156,7 +153,7 @@ def get_help(self):
         return "\n".join("[s] %s" % l for l in b)
 
     def _is_relevant(self, value):
-        return isinstance(value, self.relevant_classes)
+        return isinstance(value, self.relevant_classes) or is_item_like(value)
 
 
 def inspect_response(response, spider):
diff --git a/scrapy/spiders/feed.py b/scrapy/spiders/feed.py
index c566f0236ad..939e087e892 100644
--- a/scrapy/spiders/feed.py
+++ b/scrapy/spiders/feed.py
@@ -31,7 +31,7 @@ def process_results(self, response, results):
         processing required before returning the results to the framework core,
         for example setting the item GUIDs. It receives a list of results and
         the response which originated that results. It must return a list of
-        results (Items or Requests).
+        results (items or requests).
         """
         return results
 
@@ -52,8 +52,8 @@ def parse_nodes(self, response, nodes):
         """This method is called for the nodes matching the provided tag name
         (itertag). Receives the response and an Selector for each node.
         Overriding this method is mandatory. Otherwise, you spider won't work.
-        This method must return either a BaseItem, a Request, or a list
-        containing any of them.
+        This method must return either a BaseItem, a dict, a dataclass,
+        a Request, or a list containing any of them.
         """
 
         for selector in nodes:
diff --git a/scrapy/utils/datatypes.py b/scrapy/utils/datatypes.py
index 175f92d775e..b03a88f7383 100644
--- a/scrapy/utils/datatypes.py
+++ b/scrapy/utils/datatypes.py
@@ -9,6 +9,8 @@
 import weakref
 from collections.abc import Mapping
 
+from scrapy.utils.python import is_dataclass_instance
+
 
 class CaselessDict(dict):
 
@@ -117,3 +119,28 @@ def __init__(self, seq):
 
     def __contains__(self, item):
         return item not in self.seq
+
+
+def get_item_field(item, name, default=None):
+    """
+    Access item fields on both "regular" and dataclass-based items
+    """
+    if is_dataclass_instance(item):
+        return getattr(item, name, default)
+    else:
+        return item.get(name, default)
+
+
+def set_item_field(item, name, value):
+    """
+    Set item fields on both "regular" and dataclass-based items
+    """
+    if is_dataclass_instance(item):
+        from dataclasses import fields as dataclass_fields
+        field_names = [f.name for f in dataclass_fields(item)]
+        if name in field_names:
+            setattr(item, name, value)
+        else:
+            raise KeyError("%s does not support field: %s" % (item.__class__.__name__, name))
+    else:
+        item[name] = value
diff --git a/scrapy/utils/decorators.py b/scrapy/utils/decorators.py
index 2e2c7adc114..1f837a1a5c0 100644
--- a/scrapy/utils/decorators.py
+++ b/scrapy/utils/decorators.py
@@ -43,3 +43,31 @@ def inthread(func):
     def wrapped(*a, **kw):
         return threads.deferToThread(func, *a, **kw)
     return wrapped
+
+
+try:
+    from dataclasses import fields as dataclass_fields
+except ImportError:
+    pass
+else:
+    def subscriptable_dataclass(cls):
+        """
+        Allow dictionary-like access on dataclass instances
+        """
+
+        def __getitem__(self, key):
+            field_names = [f.name for f in dataclass_fields(self)]
+            if key in field_names:
+                return getattr(self, key)
+            raise KeyError(key)
+
+        def __setitem__(self, key, value):
+            field_names = [f.name for f in dataclass_fields(self)]
+            if key in field_names:
+                setattr(self, key, value)
+            else:
+                raise KeyError("%s does not support field: %s" % (self.__class__.__name__, key))
+
+        setattr(cls, "__getitem__", __getitem__)
+        setattr(cls, "__setitem__", __setitem__)
+        return cls
diff --git a/scrapy/utils/misc.py b/scrapy/utils/misc.py
index 52cfba208b0..81ef4343f17 100644
--- a/scrapy/utils/misc.py
+++ b/scrapy/utils/misc.py
@@ -1,9 +1,9 @@
 """Helper functions which don't fit anywhere else"""
 import ast
+import hashlib
 import inspect
 import os
 import re
-import hashlib
 import warnings
 from contextlib import contextmanager
 from importlib import import_module
@@ -12,9 +12,9 @@
 
 from w3lib.html import replace_entities
 
-from scrapy.utils.datatypes import LocalWeakReferencedCache
-from scrapy.utils.python import flatten, to_unicode
 from scrapy.item import BaseItem
+from scrapy.utils.datatypes import LocalWeakReferencedCache
+from scrapy.utils.python import flatten, is_dataclass_instance, to_unicode
 
 
 _ITERABLE_SINGLE_VALUES = dict, BaseItem, str, bytes
@@ -34,6 +34,17 @@ def arg_to_iter(arg):
         return [arg]
 
 
+def is_item_like(obj):
+    """
+    Returns True if *obj* is considered a Scrapy *item*, False otherwise.
+
+    An object is considered an *item* if it is:
+    - a scrapy.item.BaseItem or dict instance (or any subclass)
+    - a dataclass object
+    """
+    return isinstance(obj, (BaseItem, dict)) or is_dataclass_instance(obj)
+
+
 def load_object(path):
     """Load an object given its absolute object path, and return it.
 
diff --git a/scrapy/utils/python.py b/scrapy/utils/python.py
index e95a4648ed3..a2c820748f9 100644
--- a/scrapy/utils/python.py
+++ b/scrapy/utils/python.py
@@ -13,6 +13,19 @@
 from scrapy.utils.decorators import deprecated
 
 
+try:
+    from dataclasses import is_dataclass, asdict as dataclass_asdict
+except ImportError:
+    def is_dataclass_instance(_):
+        return False
+
+    def dataclass_asdict(_):
+        raise ImportError("no module named 'dataclasses'")
+else:
+    def is_dataclass_instance(obj):
+        return is_dataclass(obj) and not isinstance(obj, type)
+
+
 def flatten(x):
     """flatten(sequence) -> list
 
diff --git a/scrapy/utils/serialize.py b/scrapy/utils/serialize.py
index 9dd72ea7124..5d698131ddf 100644
--- a/scrapy/utils/serialize.py
+++ b/scrapy/utils/serialize.py
@@ -6,6 +6,7 @@
 
 from scrapy.http import Request, Response
 from scrapy.item import BaseItem
+from scrapy.utils.python import is_dataclass_instance, dataclass_asdict
 
 
 class ScrapyJSONEncoder(json.JSONEncoder):
@@ -28,6 +29,8 @@ def default(self, o):
             return str(o)
         elif isinstance(o, BaseItem):
             return dict(o)
+        elif is_dataclass_instance(o):
+            return dataclass_asdict(o)
         elif isinstance(o, Request):
             return "<%s %s %s>" % (type(o).__name__, o.method, o.url)
         elif isinstance(o, Response):
diff --git a/tests/requirements-py3.txt b/tests/requirements-py3.txt
index d207c5fb038..489ef6f1e05 100644
--- a/tests/requirements-py3.txt
+++ b/tests/requirements-py3.txt
@@ -1,4 +1,5 @@
 # Tests requirements
+dataclasses; python_version == '3.6'
 jmespath
 mitmproxy; python_version >= '3.6'
 mitmproxy<4.0.0; python_version < '3.6'
diff --git a/tests/test_engine.py b/tests/test_engine.py
index 25dee7c1f95..b8e359b0bc0 100644
--- a/tests/test_engine.py
+++ b/tests/test_engine.py
@@ -15,20 +15,22 @@
 import sys
 from urllib.parse import urlparse
 
-from twisted.internet import reactor, defer
-from twisted.web import server, static, util
+from pydispatch import dispatcher
+from twisted.internet import defer, reactor
 from twisted.trial import unittest
+from twisted.web import server, static, util
 
 from scrapy import signals
 from scrapy.core.engine import ExecutionEngine
-from scrapy.utils.test import get_crawler
-from pydispatch import dispatcher
-from tests import tests_datadir
-from scrapy.spiders import Spider
+from scrapy.http import Request
 from scrapy.item import Item, Field
 from scrapy.linkextractors import LinkExtractor
-from scrapy.http import Request
+from scrapy.spiders import Spider
+from scrapy.utils.python import dataclass_asdict, is_dataclass_instance
 from scrapy.utils.signal import disconnect_all
+from scrapy.utils.test import get_crawler
+
+from tests import tests_datadir
 
 
 class TestItem(Item):
@@ -75,6 +77,23 @@ class DictItemsSpider(TestSpider):
     item_cls = dict
 
 
+try:
+    from dataclasses import make_dataclass
+except ImportError:
+    DataClassItemsSpider = None
+else:
+    TestDataClass = make_dataclass("TestDataClass", [("name", str), ("url", str), ("price", int)])
+
+    class DataClassItemsSpider(DictItemsSpider):
+        def parse_item(self, response):
+            item = super().parse_item(response)
+            return TestDataClass(
+                name=item.get('name'),
+                url=item.get('url'),
+                price=item.get('price'),
+            )
+
+
 class ItemZeroDivisionErrorSpider(TestSpider):
     custom_settings = {
         "ITEM_PIPELINES": {
@@ -179,7 +198,10 @@ class EngineTest(unittest.TestCase):
 
     @defer.inlineCallbacks
     def test_crawler(self):
-        for spider in TestSpider, DictItemsSpider:
+
+        for spider in (TestSpider, DictItemsSpider, DataClassItemsSpider):
+            if spider is None:
+                continue
             self.run = CrawlerRun(spider)
             yield self.run.run()
             self._assert_visited_urls()
@@ -255,6 +277,8 @@ def _assert_items_error(self):
     def _assert_scraped_items(self):
         self.assertEqual(2, len(self.run.itemresp))
         for item, response in self.run.itemresp:
+            if is_dataclass_instance(item):
+                item = dataclass_asdict(item)
             self.assertEqual(item['url'], response.url)
             if 'item1.html' in item['url']:
                 self.assertEqual('Item 1 name', item['name'])
diff --git a/tests/test_item.py b/tests/test_item.py
index f70632d577f..fc11b8fc9b3 100644
--- a/tests/test_item.py
+++ b/tests/test_item.py
@@ -4,7 +4,13 @@
 from warnings import catch_warnings
 
 from scrapy.exceptions import ScrapyDeprecationWarning
-from scrapy.item import ABCMeta, DictItem, Field, Item, ItemMeta
+from scrapy.item import ABCMeta, BaseItem, DictItem, Field, Item, ItemMeta
+from scrapy.utils.misc import is_item_like
+
+try:
+    from dataclasses import make_dataclass
+except ImportError:
+    make_dataclass = None
 
 
 PY36_PLUS = (sys.version_info.major >= 3) and (sys.version_info.minor >= 6)
@@ -332,5 +338,45 @@ class SubclassedDictItem(DictItem):
             self.assertEqual(warnings[0].category, ScrapyDeprecationWarning)
 
 
+class IsItemLikeTest(unittest.TestCase):
+
+    def test_true_item(self):
+        self.assertTrue(is_item_like(BaseItem()))
+        self.assertTrue(is_item_like(Item()))
+
+    def test_true_item_subclass(self):
+        class BaseItemSubclass(BaseItem):
+            pass
+
+        class ItemSubclass(Item):
+            pass
+
+        self.assertTrue(is_item_like(BaseItemSubclass()))
+        self.assertTrue(is_item_like(ItemSubclass()))
+
+    def test_true_dict(self):
+        self.assertTrue(is_item_like(dict()))
+
+    def test_true_dict_subclass(self):
+        class DictSubclass(dict):
+            pass
+
+        self.assertTrue(is_item_like(DictSubclass()))
+
+    @unittest.skipIf(not make_dataclass, "dataclasses module is not available")
+    def test_true_dataclass(self):
+        DataClassItem = make_dataclass("DataClassItem", [("name", str)])
+        self.assertTrue(is_item_like(DataClassItem(name="foobar")))
+
+    def test_false(self):
+        class NotAnItem:
+            pass
+
+        self.assertFalse(is_item_like(NotAnItem()))
+        self.assertFalse(is_item_like(Item))
+        self.assertFalse(is_item_like(ScrapyDeprecationWarning))
+        self.assertFalse(is_item_like(ScrapyDeprecationWarning()))
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 579a85ff66b..f966aca6c63 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -9,6 +9,13 @@
 from scrapy.selector import Selector
 
 
+try:
+    from dataclasses import make_dataclass, field as dataclass_field
+except ImportError:
+    make_dataclass = None
+    dataclass_field = None
+
+
 # test items
 class NameItem(Item):
     name = Field()
@@ -460,13 +467,16 @@ class InitializationTestMixin(object):
 
     item_class = None
 
+    def to_dict(self, item):
+        return dict(item)
+
     def test_keep_single_value(self):
         """Loaded item should contain values from the initial item"""
         input_item = self.item_class(name='foo')
         il = ItemLoader(item=input_item)
         loaded_item = il.load_item()
         self.assertIsInstance(loaded_item, self.item_class)
-        self.assertEqual(dict(loaded_item), {'name': ['foo']})
+        self.assertEqual(self.to_dict(loaded_item), {'name': ['foo']})
 
     def test_keep_list(self):
         """Loaded item should contain values from the initial item"""
@@ -474,7 +484,7 @@ def test_keep_list(self):
         il = ItemLoader(item=input_item)
         loaded_item = il.load_item()
         self.assertIsInstance(loaded_item, self.item_class)
-        self.assertEqual(dict(loaded_item), {'name': ['foo', 'bar']})
+        self.assertEqual(self.to_dict(loaded_item), {'name': ['foo', 'bar']})
 
     def test_add_value_singlevalue_singlevalue(self):
         """Values added after initialization should be appended"""
@@ -483,7 +493,7 @@ def test_add_value_singlevalue_singlevalue(self):
         il.add_value('name', 'bar')
         loaded_item = il.load_item()
         self.assertIsInstance(loaded_item, self.item_class)
-        self.assertEqual(dict(loaded_item), {'name': ['foo', 'bar']})
+        self.assertEqual(self.to_dict(loaded_item), {'name': ['foo', 'bar']})
 
     def test_add_value_singlevalue_list(self):
         """Values added after initialization should be appended"""
@@ -492,7 +502,7 @@ def test_add_value_singlevalue_list(self):
         il.add_value('name', ['item', 'loader'])
         loaded_item = il.load_item()
         self.assertIsInstance(loaded_item, self.item_class)
-        self.assertEqual(dict(loaded_item), {'name': ['foo', 'item', 'loader']})
+        self.assertEqual(self.to_dict(loaded_item), {'name': ['foo', 'item', 'loader']})
 
     def test_add_value_list_singlevalue(self):
         """Values added after initialization should be appended"""
@@ -501,7 +511,7 @@ def test_add_value_list_singlevalue(self):
         il.add_value('name', 'qwerty')
         loaded_item = il.load_item()
         self.assertIsInstance(loaded_item, self.item_class)
-        self.assertEqual(dict(loaded_item), {'name': ['foo', 'bar', 'qwerty']})
+        self.assertEqual(self.to_dict(loaded_item), {'name': ['foo', 'bar', 'qwerty']})
 
     def test_add_value_list_list(self):
         """Values added after initialization should be appended"""
@@ -510,7 +520,7 @@ def test_add_value_list_list(self):
         il.add_value('name', ['item', 'loader'])
         loaded_item = il.load_item()
         self.assertIsInstance(loaded_item, self.item_class)
-        self.assertEqual(dict(loaded_item), {'name': ['foo', 'bar', 'item', 'loader']})
+        self.assertEqual(self.to_dict(loaded_item), {'name': ['foo', 'bar', 'item', 'loader']})
 
     def test_get_output_value_singlevalue(self):
         """Getting output value must not remove value from item"""
@@ -519,7 +529,7 @@ def test_get_output_value_singlevalue(self):
         self.assertEqual(il.get_output_value('name'), ['foo'])
         loaded_item = il.load_item()
         self.assertIsInstance(loaded_item, self.item_class)
-        self.assertEqual(loaded_item, dict({'name': ['foo']}))
+        self.assertEqual(self.to_dict(loaded_item), dict({'name': ['foo']}))
 
     def test_get_output_value_list(self):
         """Getting output value must not remove value from item"""
@@ -528,7 +538,7 @@ def test_get_output_value_list(self):
         self.assertEqual(il.get_output_value('name'), ['foo', 'bar'])
         loaded_item = il.load_item()
         self.assertIsInstance(loaded_item, self.item_class)
-        self.assertEqual(loaded_item, dict({'name': ['foo', 'bar']}))
+        self.assertEqual(self.to_dict(loaded_item), dict({'name': ['foo', 'bar']}))
 
     def test_values_single(self):
         """Values from initial item must be added to loader._values"""
@@ -551,6 +561,22 @@ class InitializationFromItemTest(InitializationTestMixin, unittest.TestCase):
     item_class = NameItem
 
 
+@unittest.skipIf(not make_dataclass, "dataclasses module is not available")
+class InitializationFromDataClassTest(InitializationTestMixin, unittest.TestCase):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if make_dataclass:
+            self.item_class = make_dataclass(
+                "TestDataClass",
+                [("name", list, dataclass_field(default_factory=list))],
+            )
+
+    def to_dict(self, item):
+        from dataclasses import asdict
+        return asdict(item)
+
+
 class BaseNoInputReprocessingLoader(ItemLoader):
     title_in = MapCompose(str.upper)
     title_out = TakeFirst()
diff --git a/tests/test_pipeline_files.py b/tests/test_pipeline_files.py
index f155db4cee8..84fa0e96b21 100644
--- a/tests/test_pipeline_files.py
+++ b/tests/test_pipeline_files.py
@@ -2,22 +2,40 @@
 import random
 import time
 from io import BytesIO
-from tempfile import mkdtemp
 from shutil import rmtree
-from unittest import mock
+from tempfile import mkdtemp
+from unittest import mock, skipIf
 from urllib.parse import urlparse
 
-from twisted.trial import unittest
 from twisted.internet import defer
+from twisted.trial import unittest
 
-from scrapy.pipelines.files import FilesPipeline, FSFilesStore, S3FilesStore, GCSFilesStore, FTPFilesStore
-from scrapy.item import Item, Field
 from scrapy.http import Request, Response
+from scrapy.item import Field, Item
+from scrapy.pipelines.files import (
+    FilesPipeline,
+    FSFilesStore,
+    FTPFilesStore,
+    GCSFilesStore,
+    S3FilesStore,
+)
 from scrapy.settings import Settings
-from scrapy.utils.test import assert_aws_environ, get_s3_content_and_delete
-from scrapy.utils.test import assert_gcs_environ, get_gcs_content_and_delete
-from scrapy.utils.test import get_ftp_content_and_delete
 from scrapy.utils.boto import is_botocore
+from scrapy.utils.python import is_dataclass_instance
+from scrapy.utils.test import (
+    assert_aws_environ,
+    assert_gcs_environ,
+    get_ftp_content_and_delete,
+    get_gcs_content_and_delete,
+    get_s3_content_and_delete,
+)
+
+
+try:
+    from dataclasses import make_dataclass, field as dataclass_field
+except ImportError:
+    make_dataclass = None
+    dataclass_field = None
 
 
 def _mocked_download_func(request, info):
@@ -110,43 +128,73 @@ def test_file_expired(self):
             p.stop()
 
 
-class FilesPipelineTestCaseFields(unittest.TestCase):
+class FilesPipelineTestCaseFieldsMixin:
 
     def test_item_fields_default(self):
-        class TestItem(Item):
-            name = Field()
-            file_urls = Field()
-            files = Field()
-
-        for cls in TestItem, dict:
-            url = 'http://www.example.com/files/1.txt'
-            item = cls({'name': 'item1', 'file_urls': [url]})
-            pipeline = FilesPipeline.from_settings(Settings({'FILES_STORE': 's3://example/files/'}))
-            requests = list(pipeline.get_media_requests(item, None))
-            self.assertEqual(requests[0].url, url)
-            results = [(True, {'url': url})]
-            pipeline.item_completed(results, item, None)
-            self.assertEqual(item['files'], [results[0][1]])
+        url = 'http://www.example.com/files/1.txt'
+        item = self.item_class(name='item1', file_urls=[url])
+        pipeline = FilesPipeline.from_settings(Settings({'FILES_STORE': 's3://example/files/'}))
+        requests = list(pipeline.get_media_requests(item, None))
+        self.assertEqual(requests[0].url, url)
+        results = [(True, {'url': url})]
+        item = pipeline.item_completed(results, item, None)
+        files = item.files if is_dataclass_instance(item) else item["files"]
+        self.assertEqual(files, [results[0][1]])
+        self.assertIsInstance(item, self.item_class)
 
     def test_item_fields_override_settings(self):
-        class TestItem(Item):
-            name = Field()
-            files = Field()
-            stored_file = Field()
-
-        for cls in TestItem, dict:
-            url = 'http://www.example.com/files/1.txt'
-            item = cls({'name': 'item1', 'files': [url]})
-            pipeline = FilesPipeline.from_settings(Settings({
-                'FILES_STORE': 's3://example/files/',
-                'FILES_URLS_FIELD': 'files',
-                'FILES_RESULT_FIELD': 'stored_file'
-            }))
-            requests = list(pipeline.get_media_requests(item, None))
-            self.assertEqual(requests[0].url, url)
-            results = [(True, {'url': url})]
-            pipeline.item_completed(results, item, None)
-            self.assertEqual(item['stored_file'], [results[0][1]])
+        url = 'http://www.example.com/files/1.txt'
+        item = self.item_class(name='item1', custom_file_urls=[url])
+        pipeline = FilesPipeline.from_settings(Settings({
+            'FILES_STORE': 's3://example/files/',
+            'FILES_URLS_FIELD': 'custom_file_urls',
+            'FILES_RESULT_FIELD': 'custom_files'
+        }))
+        requests = list(pipeline.get_media_requests(item, None))
+        self.assertEqual(requests[0].url, url)
+        results = [(True, {'url': url})]
+        item = pipeline.item_completed(results, item, None)
+        custom_files = item.custom_files if is_dataclass_instance(item) else item["custom_files"]
+        self.assertEqual(custom_files, [results[0][1]])
+        self.assertIsInstance(item, self.item_class)
+
+
+class FilesPipelineTestCaseFieldsDict(FilesPipelineTestCaseFieldsMixin, unittest.TestCase):
+    item_class = dict
+
+
+class FilesPipelineTestItem(Item):
+    name = Field()
+    # default fields
+    file_urls = Field()
+    files = Field()
+    # overridden fields
+    custom_file_urls = Field()
+    custom_files = Field()
+
+
+class FilesPipelineTestCaseFieldsItem(FilesPipelineTestCaseFieldsMixin, unittest.TestCase):
+    item_class = FilesPipelineTestItem
+
+
+@skipIf(not make_dataclass, "dataclasses module is not available")
+class FilesPipelineTestCaseFieldsDataClass(FilesPipelineTestCaseFieldsMixin, unittest.TestCase):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if make_dataclass:
+            self.item_class = make_dataclass(
+                "FilesPipelineTestDataClass",
+                [
+                    ("name", str),
+                    # default fields
+                    ("file_urls", list, dataclass_field(default_factory=list)),
+                    ("files", list, dataclass_field(default_factory=list)),
+                    # overridden fields
+                    ("custom_file_urls", list, dataclass_field(default_factory=list)),
+                    ("custom_files", list, dataclass_field(default_factory=list)),
+                ],
+            )
 
 
 class FilesPipelineTestCaseCustomSettings(unittest.TestCase):
diff --git a/tests/test_pipeline_images.py b/tests/test_pipeline_images.py
index 5018d6802cc..e5d85589ebb 100644
--- a/tests/test_pipeline_images.py
+++ b/tests/test_pipeline_images.py
@@ -1,21 +1,30 @@
-import io
 import hashlib
+import io
 import random
-from tempfile import mkdtemp
 from shutil import rmtree
+from tempfile import mkdtemp
+from unittest import skipIf
 
 from twisted.trial import unittest
 
-from scrapy.item import Item, Field
 from scrapy.http import Request, Response
-from scrapy.settings import Settings
+from scrapy.item import Field, Item
 from scrapy.pipelines.images import ImagesPipeline
-from scrapy.utils.python import to_bytes
+from scrapy.settings import Settings
+from scrapy.utils.python import is_dataclass_instance, to_bytes
+
+
+try:
+    from dataclasses import make_dataclass, field as dataclass_field
+except ImportError:
+    make_dataclass = None
+    dataclass_field = None
+
 
 skip = False
 try:
     from PIL import Image
-except ImportError as e:
+except ImportError:
     skip = 'Missing Python Imaging Library, install https://pypi.python.org/pypi/Pillow'
 else:
     encoders = set(('jpeg_encoder', 'jpeg_decoder'))
@@ -117,43 +126,74 @@ def thumb_key(self, url, thumb_id):
         return 'thumbsup/%s/%s.jpg' % (thumb_id, thumb_guid)
 
 
-class ImagesPipelineTestCaseFields(unittest.TestCase):
+class ImagesPipelineTestCaseFieldsMixin:
 
     def test_item_fields_default(self):
-        class TestItem(Item):
-            name = Field()
-            image_urls = Field()
-            images = Field()
-
-        for cls in TestItem, dict:
-            url = 'http://www.example.com/images/1.jpg'
-            item = cls({'name': 'item1', 'image_urls': [url]})
-            pipeline = ImagesPipeline.from_settings(Settings({'IMAGES_STORE': 's3://example/images/'}))
-            requests = list(pipeline.get_media_requests(item, None))
-            self.assertEqual(requests[0].url, url)
-            results = [(True, {'url': url})]
-            pipeline.item_completed(results, item, None)
-            self.assertEqual(item['images'], [results[0][1]])
+        url = 'http://www.example.com/images/1.jpg'
+        item = self.item_class(name='item1', image_urls=[url])
+        pipeline = ImagesPipeline.from_settings(Settings({'IMAGES_STORE': 's3://example/images/'}))
+        requests = list(pipeline.get_media_requests(item, None))
+        self.assertEqual(requests[0].url, url)
+        results = [(True, {'url': url})]
+        item = pipeline.item_completed(results, item, None)
+        images = item.images if is_dataclass_instance(item) else item["images"]
+        self.assertEqual(images, [results[0][1]])
+        self.assertIsInstance(item, self.item_class)
 
     def test_item_fields_override_settings(self):
-        class TestItem(Item):
-            name = Field()
-            image = Field()
-            stored_image = Field()
-
-        for cls in TestItem, dict:
-            url = 'http://www.example.com/images/1.jpg'
-            item = cls({'name': 'item1', 'image': [url]})
-            pipeline = ImagesPipeline.from_settings(Settings({
-                'IMAGES_STORE': 's3://example/images/',
-                'IMAGES_URLS_FIELD': 'image',
-                'IMAGES_RESULT_FIELD': 'stored_image'
-            }))
-            requests = list(pipeline.get_media_requests(item, None))
-            self.assertEqual(requests[0].url, url)
-            results = [(True, {'url': url})]
-            pipeline.item_completed(results, item, None)
-            self.assertEqual(item['stored_image'], [results[0][1]])
+        url = 'http://www.example.com/images/1.jpg'
+        item = self.item_class(name='item1', custom_image_urls=[url])
+        pipeline = ImagesPipeline.from_settings(Settings({
+            'IMAGES_STORE': 's3://example/images/',
+            'IMAGES_URLS_FIELD': 'custom_image_urls',
+            'IMAGES_RESULT_FIELD': 'custom_images'
+        }))
+        requests = list(pipeline.get_media_requests(item, None))
+        self.assertEqual(requests[0].url, url)
+        results = [(True, {'url': url})]
+        item = pipeline.item_completed(results, item, None)
+        custom_images = item.custom_images if is_dataclass_instance(item) else item["custom_images"]
+        self.assertEqual(custom_images, [results[0][1]])
+        self.assertIsInstance(item, self.item_class)
+
+
+class ImagesPipelineTestCaseFieldsDict(ImagesPipelineTestCaseFieldsMixin, unittest.TestCase):
+    item_class = dict
+
+
+class ImagesPipelineTestItem(Item):
+    name = Field()
+    # default fields
+    image_urls = Field()
+    images = Field()
+    # overridden fields
+    custom_image_urls = Field()
+    custom_images = Field()
+
+
+class ImagesPipelineTestCaseFieldsItem(ImagesPipelineTestCaseFieldsMixin, unittest.TestCase):
+    item_class = ImagesPipelineTestItem
+
+
+@skipIf(not make_dataclass, "dataclasses module is not available")
+class ImagesPipelineTestCaseFieldsDataClass(ImagesPipelineTestCaseFieldsMixin, unittest.TestCase):
+    item_class = None
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if make_dataclass:
+            self.item_class = make_dataclass(
+                "FilesPipelineTestDataClass",
+                [
+                    ("name", str),
+                    # default fields
+                    ("image_urls", list, dataclass_field(default_factory=list)),
+                    ("images", list, dataclass_field(default_factory=list)),
+                    # overridden fields
+                    ("custom_image_urls", list, dataclass_field(default_factory=list)),
+                    ("custom_images", list, dataclass_field(default_factory=list)),
+                ],
+            )
 
 
 class ImagesPipelineTestCaseCustomSettings(unittest.TestCase):
diff --git a/tests/test_utils_datatypes.py b/tests/test_utils_datatypes.py
index e5aa56eb989..790a37bb46c 100644
--- a/tests/test_utils_datatypes.py
+++ b/tests/test_utils_datatypes.py
@@ -3,9 +3,23 @@
 from collections.abc import Mapping, MutableMapping
 
 from scrapy.http import Request
-from scrapy.utils.datatypes import CaselessDict, LocalCache, LocalWeakReferencedCache, SequenceExclude
+from scrapy.item import Field, Item
+from scrapy.utils.datatypes import (
+    CaselessDict,
+    get_item_field,
+    LocalCache,
+    LocalWeakReferencedCache,
+    SequenceExclude,
+    set_item_field,
+)
 from scrapy.utils.python import garbage_collect
 
+try:
+    from dataclasses import make_dataclass, field
+except ImportError:
+    make_dataclass = None
+    field = None
+
 
 __doctests__ = ['scrapy.utils.datatypes']
 
@@ -319,5 +333,89 @@ def test_cache_without_limit(self):
             self.assertEqual(cache[r], i)
 
 
+class TestItem(Item):
+    name = Field()
+    value = Field()
+    url = Field()
+
+
+if make_dataclass:
+    DataClassItem = make_dataclass(
+        "DataClassItem",
+        [
+            ("name", list, field(default_factory=str)),
+            ("value", list, field(default_factory=int)),
+            ("url", list, field(default_factory=str)),
+        ],
+    )
+
+    from scrapy.utils.decorators import subscriptable_dataclass
+
+    @subscriptable_dataclass
+    class SubscriptableDataclassItem(DataClassItem):
+        pass
+else:
+    DataClassItem = None
+    SubscriptableDataclassItem = None
+
+
+class GetSetItemFieldsTestCase(unittest.TestCase):
+
+    def test_set_item_field(self):
+        for cls in filter(None, [dict, TestItem, DataClassItem]):
+            item = cls()
+            set_item_field(item, "name", "foobar")
+            set_item_field(item, "url", "https://example.org")
+            self.assertEqual(get_item_field(item, "name"), "foobar")
+            self.assertEqual(get_item_field(item, "url"), "https://example.org")
+
+    def test_get_default(self):
+        for cls in [dict, TestItem]:
+            item = cls()
+            self.assertEqual(get_item_field(item, "value", default=42), 42)
+            self.assertEqual(get_item_field(item, "url", default="nothing"), "nothing")
+
+    @unittest.skipIf(not DataClassItem, "dataclasses module is not available")
+    def test_non_existent_field_dataclass(self):
+        item = DataClassItem()
+        self.assertEqual(get_item_field(item, "nonexistent"), None)
+        self.assertEqual(get_item_field(item, "nonexistent", 123), 123)
+        with self.assertRaises(KeyError):
+            set_item_field(item, "nonexistent", "some value")
+
+    def test_non_existent_field_dict(self):
+        item = dict()
+        self.assertEqual(get_item_field(item, "nonexistent"), None)
+        self.assertEqual(get_item_field(item, "nonexistent", 123), 123)
+        set_item_field(item, "nonexistent", "some value")
+        self.assertEqual(get_item_field(item, "nonexistent"), "some value")
+
+    def test_non_existent_field_item(self):
+        item = TestItem()
+        self.assertEqual(get_item_field(item, "nonexistent"), None)
+        self.assertEqual(get_item_field(item, "nonexistent", 123), 123)
+        with self.assertRaises(KeyError):
+            set_item_field(item, "nonexistent", "some value")
+
+
+class SubscriptableDataclassItemTestCase(unittest.TestCase):
+
+    @unittest.skipIf(not SubscriptableDataclassItem, "dataclasses module is not available")
+    def test_subscriptable_dataclass_item(self):
+        item = SubscriptableDataclassItem()
+        item["name"] = "foobar"
+        item["url"] = "https://example.org"
+        self.assertEqual(item["name"], "foobar")
+        self.assertEqual(item["url"], "https://example.org")
+
+    @unittest.skipIf(not SubscriptableDataclassItem, "dataclasses module is not available")
+    def test_subscriptable_dataclass_item_non_existent_field(self):
+        item = SubscriptableDataclassItem()
+        with self.assertRaises(KeyError):
+            print(item["nonexistent"])
+        with self.assertRaises(KeyError):
+            item["nonexistent"] = "foobar"
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_utils_python.py b/tests/test_utils_python.py
index ec5b4c59641..419e6dc574e 100644
--- a/tests/test_utils_python.py
+++ b/tests/test_utils_python.py
@@ -7,9 +7,24 @@
 from warnings import catch_warnings
 
 from scrapy.utils.python import (
-    memoizemethod_noargs, binary_is_text, equal_attributes,
-    WeakKeyCache, get_func_args, to_bytes, to_unicode,
-    without_none_values, MutableChain)
+    binary_is_text,
+    dataclass_asdict,
+    equal_attributes,
+    get_func_args,
+    is_dataclass_instance,
+    memoizemethod_noargs,
+    MutableChain,
+    to_bytes,
+    to_unicode,
+    WeakKeyCache,
+    without_none_values,
+)
+
+
+try:
+    from dataclasses import make_dataclass
+except ImportError:
+    make_dataclass = None
 
 
 __doctests__ = ['scrapy.utils.python']
@@ -224,5 +239,25 @@ def test_without_none_values(self):
             {'one': 1, 'three': 3, 'four': 4})
 
 
+class DataclassItemsTestCase(unittest.TestCase):
+
+    @unittest.skipIf(not make_dataclass, "dataclasses module is not available")
+    def test_dataclasses_asdict(self):
+        TestDataClass = make_dataclass(
+            "TestDataClass",
+            [("name", str), ("url", str), ("price", int)],
+        )
+        self.assertTrue(is_dataclass_instance(TestDataClass("Name", "URL", 10)))
+        self.assertEqual(
+            dataclass_asdict(TestDataClass("Name", "URL", 10)),
+            dict(name="Name", url="URL", price=10)
+        )
+
+    @unittest.skipIf(make_dataclass, "dataclasses module is available")
+    def test_dataclasses_importerror(self):
+        self.assertFalse(is_dataclass_instance(object()))
+        self.assertRaises(ImportError, dataclass_asdict, object())
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_utils_serialize.py b/tests/test_utils_serialize.py
index 6dc1177792b..b7a0136aaae 100644
--- a/tests/test_utils_serialize.py
+++ b/tests/test_utils_serialize.py
@@ -1,12 +1,18 @@
+import datetime
 import json
 import unittest
-import datetime
 from decimal import Decimal
 
 from twisted.internet import defer
 
-from scrapy.utils.serialize import ScrapyJSONEncoder
 from scrapy.http import Request, Response
+from scrapy.utils.serialize import ScrapyJSONEncoder
+
+
+try:
+    from dataclasses import make_dataclass
+except ImportError:
+    make_dataclass = None
 
 
 class JsonEncoderTestCase(unittest.TestCase):
@@ -47,3 +53,16 @@ def test_encode_response(self):
         rs = self.encoder.encode(r)
         self.assertIn(r.url, rs)
         self.assertIn(str(r.status), rs)
+
+    @unittest.skipIf(not make_dataclass, "No dataclass support")
+    def test_encode_dataclass_item(self):
+        TestDataClass = make_dataclass(
+            "TestDataClass",
+            [("name", str), ("url", str), ("price", int)],
+        )
+        item = TestDataClass(name="Product", url="http://product.org", price=1)
+        encoded = self.encoder.encode(item)
+        self.assertEqual(
+            encoded,
+            '{"name": "Product", "url": "http://product.org", "price": 1}'
+        )


Code review comments:
diff_hunk: @@ -43,3 +43,31 @@ def inthread(func):
     def wrapped(*a, **kw):
         return threads.deferToThread(func, *a, **kw)
     return wrapped
+
+
+try:
+    from dataclasses import fields as dataclass_fields
+except ImportError:
+    pass
+else:
+    def subscriptable_dataclass(cls):
+        """
+        Allow dictionary-like access on dataclass instances
+        """
+
+        def __getitem__(self, key):
+            field_names = [f.name for f in dataclass_fields(self)]
path: scrapy/utils/decorators.py
text: @user1:
`if key in [f.name for f in dataclass_fields(self)]` is repeated at least 5 times in the Scrapy codebase, I wonder if we should make a small helper for that. The main reason is that it is going to be useful for people updating their middlewares to support dataclasses.

diff_hunk: @@ -360,6 +360,62 @@ Yes, by setting :setting:`DNS_RESOLVER` to ``scrapy.resolver.CachingHostnameReso
 Note that by doing so, you lose the ability to set a specific timeout for DNS requests
 (the value of the :setting:`DNS_TIMEOUT` setting is ignored).
 
+.. _faq-dataclass-items:
+
+Can I use dataclasses as items?
+-------------------------------
+
+Support for :class:`dataclasses.dataclass` objects as items was added in version 2.1.
+This works natively in Python 3.7+, or using the `dataclasses backport`_ in Python 3.6.
+
+Most of the examples in this documentation assume you are using either :class:`dict`
+or :class:`~scrapy.item.Item` objects, and access their values in a dictionary-like
+manner. However, ``dataclass`` objects expose their values trough attributes instead,
path: docs/faq.rst
text: @user1:
```suggestion
manner. However, ``dataclass`` objects expose their values through attributes instead,
```