Repository: gtalarico/pyairtable
Pull request #277 (2023-06-25 21:04:19)

Move ORM out of "experimental" status
This branch attempts to bring the ORM module out of experimental status and into a stable API, including full support for type annotations. There are lots of edge cases with an ORM, though, so I'm open to ideas on other tests we should write first. This is hopefully the last big branch before we can get ready for the 2.0 release. 

Here is a list of the major commits in this branch; this list skips a few minor cleanup/tidying commits that are not worth highlighting.

* b9d6145 is a substantial reorganization of the documentation, including:
    * A more concise "Getting Started" section
    * A longer section on interacting with data that brings together tables, formulas, and retries
    * A dedicated page for the ORM, with some auto-generated content using [cog](https://nedbatchelder.com/code/cog)
    * A more-or-less complete API reference on a single page
* 69f0434 introduces type validation on `LinkField` to ensure we aren't accidentally sending record IDs to the API from the wrong table.
* 628a58d adds a very basic integration test for a new table that has every single type of field on it, including errors and NaN results. I could've done a lot more here, but it wasn't clear it was necessary and I want to minimize dependence on integration tests vs. unit/mock tests.
* cb5e2f7 and a81a9e7 add support for dynamic resolution of model classes for linked fields.
    * `model="some.module.Model"` will use `importlib` to import `Model` from `some.module`
    * `model="Model"` will import `Model` from the same module where the field's model is defined
    * `model=LinkSelf` will ensure the `LinkField` points back to the same model where it is defined
* 6c1135d removes the `_linked_cache` property on the Model class. 
    * I spent a bit of time writing tests for this behavior, for things like "a record which links to itself should return the same object from the link field". It was fun but challenging. I wound up with something very brittle, non-thread-safe, and with a ton of edge cases and guesswork. 
    * Ultimately I don't think this is something the ORM should try to do. Let implementers deal with caching if it matters to them.
    * This commit also changes where in the data flow we fetch linked records; instead of fetching them at instance creation (when calling `to_internal_value()` on each field), we fetch them the first time `field.__get__` is called.
* e8cda82 ensures that any list field (LinkField, MultipleSelectField, etc.) always returns an empty list (never `None`) and that its type annotations reflect that. All other fields (TextField, CollaboratorField, etc.) are still annotated as `Optional`, to remind implementers that the value could be `None`.

Open questions that I haven't bottomed out on myself:

- [x] Should we change the default for `LinkField` to be `lazy=False`? Now that we don't load data until the field's `__get__` is called (see 6c1135d) it seems less useful to default to lazy loading fields.
- [ ] Are there other use cases for the ORM that we don't have captured in our regression tests? Esoteric combinations of Airtable field configuration that can produce data structures we don't recognize?
- [x] Should we do more around error handling? There are certain fields (like rollups or formulas) that can return errors or NaNs, but our type hints don't make that apparent and there's no specific handling for it. We provide `is_airtable_error()` but maybe that's not enough.


Patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index b9e6164..73eef45 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -4,164 +4,52 @@ API Reference
 =============
 
 
-Api
-*******
+Module: pyairtable
+*******************************
 
 .. autoclass:: pyairtable.Api
     :members:
 
-
-Base
-*******
-
 .. autoclass:: pyairtable.Base
     :members:
 
-
-Table
-*******
-
 .. autoclass:: pyairtable.Table
     :members:
 
-
-Retrying
-********
-
-.. versionadded:: 1.4.0
-
-:class:`~pyairtable.api.Api` accepts a ``retry_strategy=`` parameter that receives an instance of
-`urllib3.util.Retry <https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry>`_.
-
-As of 2.0.0, the default behavior is to retry requests up to five times if the Airtable API responds with
-a 429 status code, indicating you've exceeded their per-base QPS limit.
-
-To adjust the default behavior, you can use the :func:`~pyairtable.retry_strategy` function.
-For example, to increase the total number of retries:
-
-.. code-block:: python
-
-  from pyairtable import Api, retry_strategy
-  api = Api('auth_token', retry_strategy=retry_strategy(total=10))
-
-Or to retry certain types of server errors in addition to rate limiting errors:
-
-.. code-block:: python
-
-  from pyairtable import Api, retry_strategy
-  retry = retry_strategy(status_forcelist=(429, 500, 502, 503, 504))
-  api = Api('auth_token', retry_strategy=retry)
-
-You can also disable retries entirely:
-
-.. code-block:: python
-
-  from pyairtable import Api
-  api = Api('auth_token', retry_strategy=None)
-
 .. autofunction:: pyairtable.retry_strategy
 
+Module: pyairtable.api.types
+*******************************
 
+.. automodule:: pyairtable.api.types
+    :members:
 
-Parameters
-**********
-
-Airtable offers a variety of options to control how you fetch data.
-
-Most options in the Airtable API (e.g. ``sort``, ``fields``, etc.)
-have a corresponding keyword argument that can be used with fetching methods
-like :meth:`~pyairtable.Table.iterate` or :meth:`~pyairtable.Table.all`.
-
-
-.. list-table::
-   :widths: 35 65
-   :header-rows: 1
-
-   * - Keyword Argument
-     - Notes
-   * - ``max_records``
-     - |kwarg_max_records|
-   * - ``sort``
-     - |kwarg_sort|
-   * - ``view``
-     - |kwarg_view|
-   * - ``page_size``
-     - |kwarg_page_size|
-   * - ``formula``
-     - |kwarg_formula|
-   * - ``fields``
-     - |kwarg_fields|
-   * - ``cell_format``
-     - |kwarg_cell_format|
-   * - ``user_locale``
-     - |kwarg_user_locale|
-   * - ``time_zone``
-     - |kwarg_time_zone|
-   * - ``return_fields_by_field_id``
-        .. versionadded:: 1.3.0
-     - |kwarg_return_fields_by_field_id|
-
-
-Formulas
-********
-
-
-The formula module provides functionality to help you compose Airtable formulas.
-For more information see `Airtable Formula Reference <https://support.airtable.com/hc/en-us/articles/203255215-Formula-field-reference>`_
-
-Match
----------------
-
-:func:`~pyairtable.formulas.match` helps you build a formula to check for equality
-against a Python dictionary:
-
-.. code-block:: python
-
-  >>> import pyairtable
-  >>> from pyairtable.formulas import match
-  >>> table = pyairtable.Api("auth_token").table("base_id", "Contact")
-  >>> formula = match({"First Name": "John", "Age": 21})
-  >>> formula
-  "AND({First Name}='John',{Age}=21)"
-  >>> table.first(formula=formula)
-  {"id": "recUwKa6lbNSMsetH", "fields": {"First Name": "John", "Age": 21}}
-
-.. autofunction:: pyairtable.formulas.match
-
-
-Formula Helpers
----------------
-
-.. autofunction:: pyairtable.formulas.to_airtable_value
-.. autofunction:: pyairtable.formulas.escape_quotes
-
-
-Raw Formulas
-------------
 
+Module: pyairtable.formulas
+*******************************
 
-This module also includes many lower level functions you
-can use if you want to compose formulas:
+.. automodule:: pyairtable.formulas
+    :members:
 
 
-.. autofunction:: pyairtable.formulas.EQUAL
-.. autofunction:: pyairtable.formulas.FIELD
-.. autofunction:: pyairtable.formulas.AND
-.. autofunction:: pyairtable.formulas.OR
-.. autofunction:: pyairtable.formulas.FIND
-.. autofunction:: pyairtable.formulas.IF
-.. autofunction:: pyairtable.formulas.STR_VALUE
-.. autofunction:: pyairtable.formulas.LOWER
+Module: pyairtable.orm
+*******************************
 
+.. autoclass:: pyairtable.orm.Model
+    :members:
 
-Types
-*******
+Module: pyairtable.orm.fields
+*******************************
 
-.. automodule:: pyairtable.api.types
+.. automodule:: pyairtable.orm.fields
     :members:
+    :member-order: bysource
+    :exclude-members: valid_types, contains_type
+    :no-inherited-members:
+
 
-Utilities
-***********
+Module: pyairtable.utils
+*******************************
 
 .. automodule:: pyairtable.utils
     :members:
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index 70dafe4..efa10f6 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -36,13 +36,13 @@ See :ref:`Migrating from 1.x to 2.0` for detailed migration notes.
 1.4.0 (2022-12-14)
 -------------------------
 
-* Added :ref:`Retrying` ()
+* Added :func:`pyairtable.retry_strategy`.
 * Misc fix in sleep for batch requests `PR #180 <https://github.com/gtalarico/pyairtable/pull/180>`_.
 
 1.3.0 (2022-08-23)
 -------------------------
 
-* Added new ``LOWER`` formula - `PR #171 <https://github.com/gtalarico/pyairtable/pull/171>`_. See updated :ref:`Formulas`.
+* Added new ``LOWER`` formula - `PR #171 <https://github.com/gtalarico/pyairtable/pull/171>`_. See :mod:`pyairtable.formulas`.
 * Added ``match(..., match_any=True)`` to :meth:`~pyairtable.formulas.match`
 * Added ``return_fields_by_field_id`` in :meth:`~pyairtable.Api.get`
 
@@ -51,7 +51,7 @@ See :ref:`Migrating from 1.x to 2.0` for detailed migration notes.
 
 * Fixed missing rate limit in :meth:`~pyairtable.Api.batch_update` - `PR #162 <https://github.com/gtalarico/pyairtable/pull/162>`_.
 * Added support for new parameter `return_fields_by_field_id` - `PR #161 <https://github.com/gtalarico/pyairtable/pull/161>`_. See updated :ref:`Parameters`.
-* Added new ``OR`` formula - `PR #148 <https://github.com/gtalarico/pyairtable/pull/148>`_. See updated :ref:`Formulas`.
+* Added new ``OR`` formula - `PR #148 <https://github.com/gtalarico/pyairtable/pull/148>`_. See :mod:`pyairtable.formulas`.
 
 1.1.0 (2022-02-21)
 -------------------------
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 479dad2..df643f6 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -6,6 +6,7 @@ import sys
 import typing
 
 import pyairtable.api.types
+import pyairtable.orm.fields
 from pyairtable import __version__ as version
 
 extensions = [
@@ -40,7 +41,6 @@ autodoc_default_options = {
 }
 autodoc_member_order = "bysource"
 
-
 # See https://github.com/tox-dev/sphinx-autodoc-typehints#options
 typehints_defaults = "comma"
 typehints_use_signature = True
@@ -59,6 +59,11 @@ def typehints_formatter(annotation, config):
         if isinstance(value, typing._GenericAlias):  # Union, Dict, etc.
             return f":data:`~pyairtable.api.types.{name}`"
 
+    if annotation == typing.Literal[pyairtable.orm.fields._LinkFieldOptions.LinkSelf]:
+        return ":data:`~pyairtable.orm.fields.LinkSelf`"
+    if annotation == pyairtable.orm.fields.T_Linked:
+        return ":class:`~pyairtable.orm.Model`"
+
     return None
 
 
diff --git a/docs/source/experimental.rst b/docs/source/experimental.rst
deleted file mode 100644
index 1b021b5..0000000
--- a/docs/source/experimental.rst
+++ /dev/null
@@ -1,8 +0,0 @@
-Experimental
-=============
-
-.. toctree::
-    :maxdepth: 1
-
-    orm
-    metadata
diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst
index f435132..23f1162 100644
--- a/docs/source/getting-started.rst
+++ b/docs/source/getting-started.rst
@@ -9,25 +9,33 @@ Getting Started
 Installation
 ------------
 
-Add the `pyairtable <https://pypi.org/project/pyairtable>`_ library to your project just as you would any other:
+Add the `pyAirtable <https://pypi.org/project/pyairtable>`_ library to your project just as you would any other:
 
 .. code-block:: shell
 
     $ pip install pyairtable
 
 
-Connecting to Airtable
-----------------------
+Access tokens
+-------------
 
 To begin, you will need an API key or a personal access token. If you do not have one yet,
-see `the Airtable guide to personal access tokens <https://airtable.com/developers/web/guides/personal-access-tokens>`_.
-`Airtable has deprecated API keys <https://support.airtable.com/docs/airtable-api-key-deprecation-notice>`_
-and they will stop working with the API in February 2024. Both can be used interchangeably with this library.
+see the Airtable guide to `personal access tokens <https://airtable.com/developers/web/guides/personal-access-tokens>`__.
 
 This library will not persist your access token anywhere. Your access token should be securely stored.
 A common method is to `store it as an environment variable <https://www.twilio.com/blog/2017/01/how-to-set-environment-variables.html>`_
 and load it using ``os.environ``.
 
+.. note::
+
+    `Airtable has deprecated API keys <https://support.airtable.com/docs/airtable-api-key-deprecation-notice>`_
+    and they will stop working with in February 2024. Both can be used with pyAirtable,
+    and our code/docs may still refer to "API keys" instead of "access tokens".
+
+
+Quickstart
+----------
+
 The :class:`~pyairtable.Api` class represents a connection to Airtable, while the
 :class:`~pyairtable.Table` class exposes methods for retrieving, creating, and modifying
 records in Airtable:
@@ -64,204 +72,4 @@ records in Airtable:
     >>> table.delete("recwAcQdqwe21asdf")
     {'id': 'recwAcQdqwe21asdf', 'deleted': True}
 
-For more details on the available classes and methods check out the :doc:`api` section.
-
-
-Supported Endpoints
--------------------
-
-The Airtable API exposes a number of endpoints for manipulating data within tables.
-The grid below shows a comparison of the methods provided by this library alongside
-the official API equivalents.
-
-.. list-table::
-   :widths: 30 30 40
-   :header-rows: 1
-
-   * - Endpoint
-     - pyAirtable
-     - Airtable API
-   * - `Get a record <https://airtable.com/developers/web/api/get-record>`_
-     - :meth:`~pyairtable.Table.get`
-     - ``GET baseId/tableId/recordId``
-   * - `Get all records <https://airtable.com/developers/web/api/list-records>`_
-     - :meth:`~pyairtable.Table.all`
-     - ``GET baseId/tableId``
-   * - `Get matching records <https://airtable.com/developers/web/api/list-records>`_
-     - :meth:`all(formula=...) <pyairtable.Table.all>`
-     - ``GET baseId/tableId?filterByFormula=...``
-   * - `Get first match <https://airtable.com/developers/web/api/list-records>`_
-     - :meth:`~pyairtable.Table.first`
-     - ``GET baseId/tableId?maxRecords=1``
-   * - `Create a record <https://airtable.com/developers/web/api/create-records>`_
-     - :meth:`~pyairtable.Table.create`
-     - ``POST baseId/tableId``
-   * - `Update a record <https://airtable.com/developers/web/api/update-record>`_
-     - :meth:`~pyairtable.Table.update`
-     - ``PATCH baseId/tableId/recordId``
-   * - `Replace a record <https://airtable.com/developers/web/api/update-record>`_
-     - :meth:`update(replace=True) <pyairtable.Table.update>`
-     - ``PUT baseId/tableId/recordId``
-   * - `Delete a record <https://airtable.com/developers/web/api/delete-record>`_
-     - :meth:`~pyairtable.Table.delete`
-     - ``DELETE baseId/tableId/recordId``
-   * - `Create multiple records <https://airtable.com/developers/web/api/create-records>`_
-     - :meth:`~pyairtable.Table.batch_create`
-     - ``POST baseId/tableId``
-   * - `Update multiple records <https://airtable.com/developers/web/api/update-multiple-records>`_
-     - :meth:`~pyairtable.Table.batch_update`
-     - ``PATCH baseId/tableId``
-   * - `Upsert multiple records <https://airtable.com/developers/web/api/update-multiple-records>`_
-     - :meth:`~pyairtable.Table.batch_upsert`
-     - ``PATCH baseId/tableId``
-   * - `Delete multiple records <https://airtable.com/developers/web/api/delete-multiple-records>`_
-     - :meth:`~pyairtable.Table.batch_delete`
-     - ``DELETE baseId/tableId``
-
-
-Fetching Records
------------------
-
-.. warning:: |warn_rate_limit|
-
-:meth:`~pyairtable.Table.iterate`
-
-Iterate over a set of records of size ``page_size``, up until ``max_records`` or end of table.
-
-.. code-block:: python
-
-  >>> for records in table.iterate(page_size=100, max_records=1000):
-  ...     print(records)
-  ...
-  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...}, ...]
-  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Jameson', 'Age': 42}, ...}, ...]
-
-:meth:`~pyairtable.Table.all`
-
-This method returns a single list with all records in a table. Note that under the
-hood it uses :meth:`~pyairtable.Table.iterate` to fetch records so it might make
-multiple requests.
-
-.. code-block:: python
-
-  >>> table.all(sort=["Name", "-Age"])
-  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...}, ...]
-
-
-Creating Records
------------------
-
-:meth:`~pyairtable.Table.create`
-
-Creates a single record from a dictionary representing the table's fields.
-
-.. code-block:: python
-
-  >>> table.create({'Name': 'John'})
-  {'id': 'rec123asa23', 'fields': {'Name': 'John', ...}}
-
-
-:meth:`~pyairtable.Table.batch_create`
-
-Create multiple records from a list of :class:`~pyairtable.api.types.Fields` dicts.
-
-.. code-block:: python
-
-  >>> table.batch_create([{'Name': 'John'}, ...])
-  [{'id': 'rec123asa23', 'fields': {'Name': 'John'}}, ...]
-
-
-Updating Records
------------------
-
-:meth:`~pyairtable.Table.update`
-
-Updates a single record for the provided ``record_id`` using a
-dictionary representing the table's fields.
-
-.. code-block:: python
-
-  >>> table.update('recwPQIfs4wKPyc9D', {"Age": 21})
-  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {"Name": "John", "Age": 21}}, ...]
-
-
-:meth:`~pyairtable.Table.batch_update`
-
-Update multiple records from a list of :class:`~pyairtable.api.types.UpdateRecordDict`.
-
-.. code-block:: python
-
-  >>> table.batch_update([{"id": "recwPQIfs4wKPyc9D", "fields": {"Name": "Matt"}}, ...])
-  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {"Name": "Matt", ...}}, ...]
-
-
-:meth:`~pyairtable.Table.batch_upsert`
-
-.. versionadded:: 1.5.0
-
-Batch upsert (create or update) records from a list of records. For details on the behavior
-of this Airtable API endpoint, see `Update multiple records`_.
-
-.. code-block:: python
-
-  >>> table.batch_upsert(
-  ...     [{"id": "recwPQIfs4wKPyc9D", "fields": {"Name": "Matt"}}, ...],
-  ...     key_fields=["Name"]
-  ... )
-  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {'Name': 'Matt', ...}}, ...]
-
-
-Deleting Records
------------------
-
-:meth:`~pyairtable.Table.delete`
-
-Deletes a single record using the provided ``record_id``.
-
-.. code-block:: python
-
-  >>> table.delete('recwPQIfs4wKPyc9D')
-  {'deleted': True, 'id': 'recwPQIfs4wKPyc9D'}
-
-:meth:`~pyairtable.Table.batch_delete`
-
-Batch delete records using a list of record ids.
-
-.. code-block:: python
-
-  >>> table.batch_delete(['recwPQIfs4wKPyc9D', 'recwAcQdqwe21asdf'])
-  [{'deleted': True, 'id': 'recwPQIfs4wKPyc9D'},
-   {'deleted': True, 'id': 'recwAcQdqwe21asdf'}]
-
-
-Return Values
--------------
-
-This library will return records as :class:`~pyairtable.api.types.RecordDict`.
-
-.. code-block:: python
-
-  >>> table.all()
-  [
-      {
-          'id': 'recwPQIfs4wKPyc9D',
-          'createdTime': '2017-03-14T22:04:31.000Z'
-          'fields': {
-              'Name': 'Alice',
-          },
-      },
-      {
-          'id': 'rechOLltN9SpPHq5o',
-          'createdTime': '2017-03-20T15:21:50.000Z'
-          'fields': {
-              'Name': 'Bob',
-          },
-      },
-      {
-          'id': 'rec5eR7IzKSAOBHCz',
-          'createdTime': '2017-08-05T21:47:52.000Z'
-          'fields': {
-              'Name': 'Carol',
-          },
-      }
-  ]
+See :doc:`tables` for more details on how to get and set data in Airtable.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index e16ef6c..715ac66 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -23,9 +23,11 @@ pyAirtable
    :hidden:
 
    getting-started
-   api
-   experimental
+   tables
+   orm
+   metadata
    migrations
+   api
 
 
 .. toctree::
@@ -34,6 +36,6 @@ pyAirtable
 
    about
    changelog
-   Airtable Api Docs <https://airtable.com/api>
+   Airtable API Docs <https://airtable.com/api>
    GitHub <https://github.com/gtalarico/pyairtable>
    PyPI <https://pypi.org/project/pyairtable/>
diff --git a/docs/source/metadata.rst b/docs/source/metadata.rst
index 67c943e..abb9e7b 100644
--- a/docs/source/metadata.rst
+++ b/docs/source/metadata.rst
@@ -1,19 +1,12 @@
 .. include:: substitutions.rst
 
-Metadata Api
+Metadata
 ==============
 
-The metadata api gives you the ability to list all of your bases, tables, fields, and views.
+The metadata API gives you the ability to list all of your bases, tables, fields, and views.
 
 .. warning::
-    This api is experimental
-
-.. warning::
-    If you want to develop an integration using the Metadata API,
-    you must register `here <https://airtable.com/api/meta>`_ for access
-    and to receive a client secret.
-    Enterprise Airtable accounts do not require a
-    separate Metadata API client secret.
+    This API is experimental and subject to change.
 
 
 .. automodule:: pyairtable.metadata
diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst
index b157e75..d9d7360 100644
--- a/docs/source/migrations.rst
+++ b/docs/source/migrations.rst
@@ -23,7 +23,7 @@ Changes to Api, Base, and Table
 no longer inherit from the same base class. Each has its own scope of responsibility and has
 methods which refer to the other classes as needed. See :ref:`Getting Started`.
 
-For a period of time, the constructor for :class:`~pyairtable.Base`, and :class:`~pyairtable.Table`
+For a period of time, the constructor for :class:`~pyairtable.Base` and :class:`~pyairtable.Table`
 will remain backwards-compatible with the previous approach (passing in ``str`` values),
 but doing so will produce deprecation warnings.
 
@@ -57,18 +57,20 @@ Retry by Default
 * By default, the library will retry requests up to five times if it receives
   a 429 status code from Airtable, indicating the base has exceeded its QPS limit.
 
-
 Changes to the ORM
 ------------------
 
 * :meth:`Model.all <pyairtable.orm.Model.all>` and :meth:`Model.first <pyairtable.orm.Model.first>`
   return instances of the model class instead of returning dicts.
+* :class:`~pyairtable.orm.fields.LinkField` now defaults to ``lazy=False``. The first time your code
+  accesses the field, it will perform one or more API calls to retrieve field data for linked records.
+  You can disable this by passing ``lazy=True`` when creating the field.
 
 Changes to types
 ----------------
 
 * All functions and methods in this library have full type annotations that will pass ``mypy --strict``.
-  See the :ref:`Types` section for more information on the types this library accepts and returns.
+  See the :ref:`types <Module: pyairtable.api.types>` module for more information on the types this library accepts and returns.
 
 
 Migrating from 0.x to 1.0
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index 8fa1cfe..16e1c93 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -3,63 +3,397 @@
 ORM
 ==============
 
-.. warning:: This feature is experimental. Feel free to submit suggestions or feedback in our
-    `Github repo <https://github.com/gtalarico/pyairtable>`_
-
-
-Model
-******
-
-.. automodule:: pyairtable.orm.model
-
-.. autoclass:: pyairtable.orm.model.Model
-    :members:
-    :undoc-members:
-
-Fields
-******
-
-.. automodule:: pyairtable.orm.fields
-
-
-Field Types
------------
-.. autoclass:: pyairtable.orm.fields.AutoNumberField
-.. autoclass:: pyairtable.orm.fields.BarcodeField
-.. autoclass:: pyairtable.orm.fields.ButtonField
-.. autoclass:: pyairtable.orm.fields.CheckboxField
-.. autoclass:: pyairtable.orm.fields.CollaboratorField
-.. autoclass:: pyairtable.orm.fields.CountField
-.. autoclass:: pyairtable.orm.fields.CreatedByField
-.. autoclass:: pyairtable.orm.fields.CreatedTimeField
-.. autoclass:: pyairtable.orm.fields.CurrencyField
-.. autoclass:: pyairtable.orm.fields.DateField
-.. autoclass:: pyairtable.orm.fields.DatetimeField
-.. autoclass:: pyairtable.orm.fields.DurationField
-.. autoclass:: pyairtable.orm.fields.EmailField
-.. autoclass:: pyairtable.orm.fields.ExternalSyncSourceField
-.. autoclass:: pyairtable.orm.fields.FloatField
-.. autoclass:: pyairtable.orm.fields.IntegerField
-.. autoclass:: pyairtable.orm.fields.LastModifiedByField
-.. autoclass:: pyairtable.orm.fields.LastModifiedTimeField
-.. autoclass:: pyairtable.orm.fields.LinkField
-.. autoclass:: pyairtable.orm.fields.ListField
-.. autoclass:: pyairtable.orm.fields.LookupField
-.. autoclass:: pyairtable.orm.fields.MultipleAttachmentsField
-.. autoclass:: pyairtable.orm.fields.MultipleCollaboratorsField
-.. autoclass:: pyairtable.orm.fields.MultipleSelectField
-.. autoclass:: pyairtable.orm.fields.NumberField
-.. autoclass:: pyairtable.orm.fields.PercentField
-.. autoclass:: pyairtable.orm.fields.PhoneNumberField
-.. autoclass:: pyairtable.orm.fields.RatingField
-.. autoclass:: pyairtable.orm.fields.RichTextField
-.. autoclass:: pyairtable.orm.fields.SelectField
-.. autoclass:: pyairtable.orm.fields.TextField
-.. autoclass:: pyairtable.orm.fields.UrlField
-
-
-Constants
----------
-.. autodata:: pyairtable.orm.fields.ALL_FIELDS
-.. autodata:: pyairtable.orm.fields.READONLY_FIELDS
-.. autodata:: pyairtable.orm.fields.FIELD_TYPES_TO_CLASSES
+
+Defining Models
+---------------
+
+The :class:`~pyairtable.orm.Model` class allows you create ORM-style classes for your Airtable tables.
+
+.. code-block: python::
+
+    from pyairtable.orm import Model, fields as F
+    class Contact(Model):
+        first_name = F.TextField("First Name")
+        last_name = F.TextField("Last Name")
+        email = F.EmailField("Email")
+        is_registered = F.CheckboxField("Registered")
+        company = F.LinkField("Company", Company, lazy=False)
+
+        class Meta:
+            base_id = "appaPqizdsNHDvlEm"
+            table_name = "Contact"
+            api_key = "keyapikey"
+
+
+Once you have a model, you can create new objects to represent your
+Airtable records. Call :meth:`~pyairtable.orm.Model.save` to save the
+newly created object to the Airtable API.
+
+    >>> contact = Contact(
+    ...     first_name="Mike",
+    ...     last_name="McDonalds",
+    ...     email="mike@mcd.com",
+    ...     is_registered=False
+    ... )
+    >>> assert contact.id is None
+    >>> contact.exists()
+    False
+    >>> assert contact.save()
+    >>> contact.exists()
+    True
+    >>> contact.id
+    'recS6qSLw0OCA6Xul'
+
+
+You can read and modify attributes, then call :meth:`~pyairtable.orm.Model.save`
+when you're ready to save your changes to the API.
+
+    >>> contact = Contact.from_id("recS6qSLw0OCA6Xul")
+    >>> assert contact.is_registered is False
+    >>> contact.is_registered = True
+    >>> contact.save()
+
+To refresh a record from the API, use :meth:`~pyairtable.orm.Model.fetch`:
+
+    >>> contact.is_registered = False
+    >>> contact.fetch()
+    >>> contact.is_registered
+    True
+
+Finally, you can use :meth:`~pyairtable.orm.Model.delete` to delete the record:
+
+    >>> contact.delete()
+    True
+
+There are also :meth:`~pyairtable.orm.Model.batch_save` and
+:meth:`~pyairtable.orm.Model.batch_delete` for when you need to
+create, modify, or delete several records at once:
+
+    >>> contacts = Contact.all()
+    >>> contacts.append(Contact(first_name="Alice", email="alice@example.com"))
+    >>> Contact.batch_save(contacts)
+    >>> Contact.batch_delete(contacts)
+
+
+Supported Field Types
+-----------------------------
+
+The following grid maps each of the supported field types in pyAirtable
+to the Airtable field type. Any field with a lock icon is read-only by default.
+For more information on how the Airtable API represents each of its field types,
+read `Field types and cell values <https://airtable.com/developers/web/api/field-model>`__.
+
+..  [[[cog
+    import re
+    from operator import attrgetter
+    from pyairtable.orm import fields
+
+    cog.outl("..")
+    cog.outl(".. list-table::")
+    cog.outl("   :header-rows: 1\n")
+    cog.outl("   * - ORM field class")
+    cog.outl("     - Airtable field type(s)")
+
+    for cls in sorted(fields.ALL_FIELDS, key=attrgetter("__name__")):
+        links = re.findall(r"`.+? <.*?field-model.*?>`", cls.__doc__ or "")
+        ro = ' 🔒' if cls.readonly else ''
+        cog.outl(f"   * - :class:`~pyairtable.orm.fields.{cls.__name__}`{ro}")
+        cog.outl(f"     - {', '.join(f'{link}__' for link in links) if links else '(see docs)'}")
+    ]]]
+..
+.. list-table::
+   :header-rows: 1
+
+   * - ORM field class
+     - Airtable field type(s)
+   * - :class:`~pyairtable.orm.fields.AutoNumberField` 🔒
+     - `Auto number <https://airtable.com/developers/web/api/field-model#autonumber>`__
+   * - :class:`~pyairtable.orm.fields.BarcodeField`
+     - `Barcode <https://airtable.com/developers/web/api/field-model#barcode>`__
+   * - :class:`~pyairtable.orm.fields.ButtonField` 🔒
+     - `Button <https://airtable.com/developers/web/api/field-model#button>`__
+   * - :class:`~pyairtable.orm.fields.CheckboxField`
+     - `Checkbox <https://airtable.com/developers/web/api/field-model#checkbox>`__
+   * - :class:`~pyairtable.orm.fields.CollaboratorField`
+     - `Collaborator <https://airtable.com/developers/web/api/field-model#collaborator>`__
+   * - :class:`~pyairtable.orm.fields.CountField` 🔒
+     - `Count <https://airtable.com/developers/web/api/field-model#count>`__
+   * - :class:`~pyairtable.orm.fields.CreatedByField` 🔒
+     - `Created by <https://airtable.com/developers/web/api/field-model#createdby>`__
+   * - :class:`~pyairtable.orm.fields.CreatedTimeField` 🔒
+     - `Created time <https://airtable.com/developers/web/api/field-model#createdtime>`__
+   * - :class:`~pyairtable.orm.fields.CurrencyField`
+     - `Currency <https://airtable.com/developers/web/api/field-model#currencynumber>`__
+   * - :class:`~pyairtable.orm.fields.DateField`
+     - `Date <https://airtable.com/developers/web/api/field-model#dateonly>`__
+   * - :class:`~pyairtable.orm.fields.DatetimeField`
+     - `Date and time <https://airtable.com/developers/web/api/field-model#dateandtime>`__
+   * - :class:`~pyairtable.orm.fields.DurationField`
+     - `Duration <https://airtable.com/developers/web/api/field-model#durationnumber>`__
+   * - :class:`~pyairtable.orm.fields.EmailField`
+     - `Email <https://airtable.com/developers/web/api/field-model#email>`__
+   * - :class:`~pyairtable.orm.fields.ExternalSyncSourceField` 🔒
+     - `Sync source <https://airtable.com/developers/web/api/field-model#syncsource>`__
+   * - :class:`~pyairtable.orm.fields.FloatField`
+     - `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__
+   * - :class:`~pyairtable.orm.fields.IntegerField`
+     - `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__
+   * - :class:`~pyairtable.orm.fields.LastModifiedByField` 🔒
+     - `Last modified by <https://airtable.com/developers/web/api/field-model#lastmodifiedby>`__
+   * - :class:`~pyairtable.orm.fields.LastModifiedTimeField` 🔒
+     - `Last modified time <https://airtable.com/developers/web/api/field-model#lastmodifiedtime>`__
+   * - :class:`~pyairtable.orm.fields.LinkField`
+     - `Link to another record <https://airtable.com/developers/web/api/field-model#foreignkey>`__
+   * - :class:`~pyairtable.orm.fields.LookupField` 🔒
+     - `Lookup <https://airtable.com/developers/web/api/field-model#lookup>`__
+   * - :class:`~pyairtable.orm.fields.MultipleAttachmentsField`
+     - `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`__
+   * - :class:`~pyairtable.orm.fields.MultipleCollaboratorsField`
+     - `Multiple Collaborators <https://airtable.com/developers/web/api/field-model#multicollaborator>`__
+   * - :class:`~pyairtable.orm.fields.MultipleSelectField`
+     - `Multiple select <https://airtable.com/developers/web/api/field-model#multiselect>`__
+   * - :class:`~pyairtable.orm.fields.NumberField`
+     - `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__
+   * - :class:`~pyairtable.orm.fields.PercentField`
+     - `Percent <https://airtable.com/developers/web/api/field-model#percentnumber>`__
+   * - :class:`~pyairtable.orm.fields.PhoneNumberField`
+     - `Phone <https://airtable.com/developers/web/api/field-model#phone>`__
+   * - :class:`~pyairtable.orm.fields.RatingField`
+     - `Rating <https://airtable.com/developers/web/api/field-model#rating>`__
+   * - :class:`~pyairtable.orm.fields.RichTextField`
+     - `Rich text <https://airtable.com/developers/web/api/field-model#rich-text>`__
+   * - :class:`~pyairtable.orm.fields.SelectField`
+     - `Single select <https://airtable.com/developers/web/api/field-model#select>`__
+   * - :class:`~pyairtable.orm.fields.TextField`
+     - `Single line text <https://airtable.com/developers/web/api/field-model#simpletext>`__, `Long text <https://airtable.com/developers/web/api/field-model#multilinetext>`__
+   * - :class:`~pyairtable.orm.fields.UrlField`
+     - `Url <https://airtable.com/developers/web/api/field-model#urltext>`__
+.. [[[end]]]
+
+
+Formulas, Rollups, and Lookups
+----------------------------------
+
+The data type of "formula", "rollup", and "lookup" fields will depend on the underlying fields
+they reference, and pyAirtable cannot easily guess at those fields' types.
+
+If you need to refer to one of these fields in the ORM, you need to know what type of value
+you expect it to contain. You can then declare that as a read-only field:
+
+.. code-block:: python
+
+    from pyairtable.orm import fields as F
+
+    class MyTable(Model):
+        class Meta: ...
+
+        formula_field = F.TextField("My Formula", readonly=True)
+        rollup_field = F.IntegerField("Row Count", readonly=True)
+        lookup_field = F.LookupField[str]("My Lookup", readonly=True)
+
+
+.. note::
+    :class:`~pyairtable.orm.fields.LookupField` will always return a list of values,
+    even if there is only a single value shown in the Airtable UI.
+
+
+Error Values
+------------
+
+Airtable will return special values to represent errors from invalid formulas,
+division by zero, or other sorts of issues. These will be returned by the ORM as-is.
+Read more at `Common formula errors and how to fix them <https://support.airtable.com/docs/common-formula-errors-and-how-to-fix-them>`_.
+
+You can check for errors using the :func:`~pyairtable.api.types.is_airtable_error` function:
+
+  >>> record = MyTable.from_id("recyhb9UNkEMaZtYA")
+  >>> record.formula_field
+  {'error': '#ERROR!'}
+  >>> record.rollup_field
+  {'specialValue': 'NaN'}
+  >>> record.lookup_field
+  [{'error': '#ERROR!'}]
+  >>> from pyairtable.api.types import is_airtable_error
+  >>> is_airtable_error(record.formula_field)
+  True
+  >>> is_airtable_error(record.rollup_field)
+  True
+  >>> is_airtable_error(record.lookup_field[0])
+  True
+
+
+Linked Records
+----------------
+
+In addition to standard data type fields, the :class:`~pyairtable.orm.fields.LinkField`
+class offers a special behaviour that can fetch linked records, so that you can
+traverse between related records.
+
+.. code-block:: python
+
+    from pyairtable.orm import Model, fields as F as F
+
+    class Company(Model):
+        class Meta: ...
+
+        name = F.TextField("Name")
+
+    class Person(Model):
+        class Meta: ...
+
+        name = F.TextField("Name")
+        company = F.LinkField("Company", Company)
+
+.. code-block:: python
+
+    >>> person = Person.from_id("recZ6qSLw0OCA61ul")
+    >>> person.company
+    [<Company id='recqSk20OCrB13lZ7'>]
+    >>> person.company[0].name
+    'Acme Corp'
+
+pyAirtable will not retrieve field values for a model's linked records until the
+first time you access that field. So in the example above, the fields for Company
+were loaded when ``person.company`` was called for the first time. After that,
+the Company models are persisted, and won't be refreshed until you call
+:meth:`~pyairtable.orm.Model.fetch`.
+
+.. note::
+    :class:`~pyairtable.orm.fields.LinkField` will always return a list of values,
+    even if there is only a single value shown in the Airtable UI. It will not
+    respect the `prefersSingleRecordLink <https://airtable.com/developers/web/api/field-model#foreignkey-fieldtype-options-preferssinglerecordlink>`_
+    field configuration option, because the API will *always* return linked fields
+    as a list of record IDs.
+
+
+Cyclical links
+""""""""""""""
+
+If you need to model bidirectional links between two tables, you'll need to create one of
+the fields before the linked model is created. pyAirtable provides a few options to
+address this:
+
+1. You can provide a ``str`` that is the fully qualified module and class name.
+   For example, ``model="your.module.Model"`` will import the ``Model`` class from ``your.module``.
+2. You can provide a ``str`` that is *just* the class name, and it will be imported
+   from the same module as the model class.
+3. You can provide the sentinel value ``LinkSelf``, and the link field will point to
+   the same model where the link field is created.
+
+.. code-block:: python
+
+    from pyairtable.orm import Model, fields as F as F
+
+    class Company(Model):
+        class Meta: ...
+
+        name = F.TextField("Name")
+        employees = F.LinkField("Employees", "path.to.Person")  # option 1
+
+    class Person(Model):
+        class Meta: ...
+
+        name = F.TextField("Name")
+        company = F.LinkField[Company]("Company", Company)
+        manager = F.LinkField["Person"]("Manager", "Person")  # option 2
+        reports = F.LinkField["Person"]("Reports", F.LinkSelf)  # option 3
+
+.. code-block:: python
+
+    >>> person = Person.from_id("recZ6qSLw0OCA61ul")
+    >>> person.manager
+    [<Person id='recSLw0OCA61ulZ6q'>]
+    >>> person.manager[0].reports
+    [<Person id='recZ6qSLw0OCA61ul'>, ...]
+    >>> person.company[0].employees
+    [<Person id='recZ6qSLw0OCA61ul'>, <Person id='recSLw0OCA61ulZ6q'>, ...]
+
+Breaking down the :class:`~pyairtable.orm.fields.LinkField` invocation above,
+there are four components:
+
+.. code-block:: python
+
+      manager = F.LinkField["Person"]("Manager", "path.to.Person")
+     #^^^^^^^               ^^^^^^^^  ^^^^^^^^^  ^^^^^^^^^^^^^^^^
+     #  (1)                    (2)       (3)           (4)
+
+1. The name of the attribute on the model
+2. Type annotation (optional, for mypy users)
+3. Airtable's field name for the API
+4. The model class, the path to the model class, or :data:`~pyairtable.orm.fields.LinkSelf`
+
+
+ORM Limitations
+------------------
+
+Linked records don't get saved automatically
+""""""""""""""""""""""""""""""""""""""""""""
+
+pyAirtable will not attempt to recursively save any linked records. Because of this,
+you cannot save a record via ORM unless you've first created all of its linked records:
+
+    >>> alice = Person.from_id("recWcnG8712AqNuHw")
+    >>> alice.manager = [Person()]
+    >>> alice.save()
+    Traceback (most recent call last):
+      ...
+    ValueError: Person.manager contains an unsaved record
+
+Field values don't get refreshed after saving a record
+""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+pyAirtable will not refresh models when calling :meth:`~pyairtable.orm.Model.save`,
+since certain field types (like :class:`~pyairtable.orm.fields.LinkField`) return
+lists of objects which you might not want pyAirtable to modify or discard. If you
+want to reload the values of all fields after saving (for example, to refresh the
+value of formula fields) then you need to call :meth:`~pyairtable.orm.Model.fetch`.
+
+For example:
+
+.. code-block:: python
+
+    class Person(Model):
+        class Meta: ...
+
+        name = F.TextField("Name")
+        manager = F.LinkField["Person"]("Manager", "Person")
+        # This field is a formula: {Manager} != BLANK()
+        has_manager = F.IntegerField("Has Manager?", readonly=True)
+
+
+    bob = Person.from_id("rec2AqNuHwWcnG871")
+    assert bob.manager == []
+    assert bob.has_manager == 0
+
+    bob.manager = [alice]
+    bob.save()
+    assert bob.has_manager == 0
+
+    bob.fetch()
+    assert bob.has_manager == 1
+
+Type annotations don't account for possible formula errors
+""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+The ORM module does its best to give meaningful type annotations for each field.
+However, it is not feasible for the ORM to determine which fields' underlying types
+might return an error code, and to annotate it accordingly.
+
+Taking the same example as above...
+
+.. code-block:: python
+
+    class Person(Model):
+        class Meta: ...
+
+        name = F.TextField("Name")
+        has_manager = F.IntegerField("Has Manager?", readonly=True)  # formula
+
+...the type annotation of ``Person().has_manager`` will appear as ``int`` to mypy
+and to most type-aware code editors. It is nonetheless possible that if the formula
+becomes invalid, ``person.has_manager`` will return ``{'error': '#ERROR!'}``
+(which is obviously not an ``int``).
+
+In most cases you probably want your code to fail quickly and loudly if there is an
+error value coming back from the Airtable API. In the unusual cases where you want
+to gracefully handle an error and move on, use :func:`~pyairtable.api.types.is_airtable_error`.
diff --git a/docs/source/substitutions.rst b/docs/source/substitutions.rst
index 3280dc9..b06a1ee 100644
--- a/docs/source/substitutions.rst
+++ b/docs/source/substitutions.rst
@@ -60,8 +60,3 @@
 
 .. |kwarg_return_fields_by_field_id| replace:: An optional boolean value that lets you return field objects where the
     key is the field id. This defaults to `false`, which returns field objects where the key is the field name.
-
-.. |warn_rate_limit| replace::
-    Due to Airtable API limits, the library inserts a 0.2 second delay in between requests.
-    If you retrieve many pages of records, expect the library to take several seconds to return.
-    Read more: `Rate limits <https://airtable.com/developers/web/api/rate-limits>`__
diff --git a/docs/source/tables.rst b/docs/source/tables.rst
new file mode 100644
index 0000000..e06082a
--- /dev/null
+++ b/docs/source/tables.rst
@@ -0,0 +1,272 @@
+Working with Tables
+==============================
+
+.. include:: substitutions.rst
+
+
+.. warning::
+    Airtable imposes a `rate limit <https://airtable.com/developers/web/api/rate-limits>`__
+    of 5 QPS per base. If you exceed that limit, their API will return 429 errors for a moment.
+    By default, pyAirtable will retry 429 errors up to five times, but retrieving many pages
+    of records might take several seconds. Read more at :func:`pyairtable.retry_strategy`.
+
+
+Supported Endpoints
+-------------------
+
+The Airtable API exposes a number of endpoints for manipulating data within tables.
+The grid below maps Airtable's official API endpoints to this library's methods.
+
+.. list-table::
+   :header-rows: 1
+
+   * - Airtable Endpoint
+     - pyAirtable Method
+   * - `Get a record <https://airtable.com/developers/web/api/get-record>`_
+     - :meth:`~pyairtable.Table.get`
+   * - `Get all records <https://airtable.com/developers/web/api/list-records>`_
+     - :meth:`~pyairtable.Table.all`
+   * - `Get matching records <https://airtable.com/developers/web/api/list-records>`_
+     - :meth:`all(formula=...) <pyairtable.Table.all>`
+   * - `Get first match <https://airtable.com/developers/web/api/list-records>`_
+     - :meth:`~pyairtable.Table.first`
+   * - `Create a record <https://airtable.com/developers/web/api/create-records>`_
+     - :meth:`~pyairtable.Table.create`
+   * - `Update a record <https://airtable.com/developers/web/api/update-record>`_
+     - :meth:`~pyairtable.Table.update`
+   * - `Replace a record <https://airtable.com/developers/web/api/update-record>`_
+     - :meth:`update(replace=True) <pyairtable.Table.update>`
+   * - `Delete a record <https://airtable.com/developers/web/api/delete-record>`_
+     - :meth:`~pyairtable.Table.delete`
+   * - `Create multiple records <https://airtable.com/developers/web/api/create-records>`_
+     - :meth:`~pyairtable.Table.batch_create`
+   * - `Update multiple records <https://airtable.com/developers/web/api/update-multiple-records>`_
+     - :meth:`~pyairtable.Table.batch_update`
+   * - `Upsert multiple records <https://airtable.com/developers/web/api/update-multiple-records>`_
+     - :meth:`~pyairtable.Table.batch_upsert`
+   * - `Delete multiple records <https://airtable.com/developers/web/api/delete-multiple-records>`_
+     - :meth:`~pyairtable.Table.batch_delete`
+
+
+Fetching Records
+-----------------
+
+:meth:`~pyairtable.Table.iterate`
+
+Iterate over a set of records of size ``page_size``, up until ``max_records`` or end of table.
+
+.. code-block:: python
+
+  >>> for records in table.iterate(page_size=100, max_records=1000):
+  ...     print(records)
+  ...
+  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...}, ...]
+  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Jameson', 'Age': 42}, ...}, ...]
+
+:meth:`~pyairtable.Table.all`
+
+This method returns a single list with all records in a table. Note that under the
+hood it uses :meth:`~pyairtable.Table.iterate` to fetch records so it might make
+multiple requests.
+
+.. code-block:: python
+
+  >>> table.all(sort=["Name", "-Age"])
+  [{'id': 'rec123asa23', 'fields': {'Last Name': 'Alfred', 'Age': 84}, ...}, ...]
+
+
+Parameters
+**********
+
+Airtable's API offers a variety of options to control how you fetch data.
+
+Most options in the Airtable API (e.g. ``sort``, ``fields``, etc.)
+have a corresponding keyword argument that can be used with fetching methods
+like :meth:`~pyairtable.Table.iterate` or :meth:`~pyairtable.Table.all`.
+
+
+.. list-table::
+   :widths: 35 65
+   :header-rows: 1
+
+   * - Keyword Argument
+     - Notes
+   * - ``max_records``
+     - |kwarg_max_records|
+   * - ``sort``
+     - |kwarg_sort|
+   * - ``view``
+     - |kwarg_view|
+   * - ``page_size``
+     - |kwarg_page_size|
+   * - ``formula``
+     - |kwarg_formula|
+   * - ``fields``
+     - |kwarg_fields|
+   * - ``cell_format``
+     - |kwarg_cell_format|
+   * - ``user_locale``
+     - |kwarg_user_locale|
+   * - ``time_zone``
+     - |kwarg_time_zone|
+   * - ``return_fields_by_field_id``
+        .. versionadded:: 1.3.0
+     - |kwarg_return_fields_by_field_id|
+
+
+Formulas
+********
+
+The :mod:`pyairtable.formulas` module provides functionality to help you compose
+`Airtable formulas <https://support.airtable.com/hc/en-us/articles/203255215-Formula-field-reference>`_.
+
+* :func:`~pyairtable.formulas.match` checks field values from a Python ``dict``:
+
+  .. code-block:: python
+
+      >>> from pyairtable.formulas import match
+      >>> formula = match({"First Name": "John", "Age": 21})
+      >>> formula
+      "AND({First Name}='John',{Age}=21)"
+      >>> table.first(formula=formula)
+      {"id": "recUwKa6lbNSMsetH", "fields": {"First Name": "John", "Age": 21}}
+
+* :func:`~pyairtable.formulas.to_airtable_value` converts a Python value
+  to an expression that can be included in a formula:
+
+  .. code-block:: python
+
+      >>> from pyairtable.formulas import to_airtable_value
+      >>> to_airtable_value(1)
+      1
+      >>> to_airtable_value(datetime.date.today())
+      '2023-06-13'
+
+For more on generating formulas, look over the :mod:`pyairtable.formulas` API reference.
+
+
+Retries
+*******
+
+As of 2.0.0, the default behavior is to retry requests up to five times if the Airtable API responds with
+a 429 status code, indicating you've exceeded their per-base QPS limit. To adjust the default behavior,
+you can use the :func:`~pyairtable.retry_strategy` function.
+
+
+Creating Records
+-----------------
+
+:meth:`~pyairtable.Table.create`
+
+Creates a single record from a dictionary representing the table's fields.
+
+.. code-block:: python
+
+  >>> table.create({'Name': 'John'})
+  {'id': 'rec123asa23', 'fields': {'Name': 'John', ...}}
+
+
+:meth:`~pyairtable.Table.batch_create`
+
+Create multiple records from a list of :class:`~pyairtable.api.types.Fields` dicts.
+
+.. code-block:: python
+
+  >>> table.batch_create([{'Name': 'John'}, ...])
+  [{'id': 'rec123asa23', 'fields': {'Name': 'John'}}, ...]
+
+
+Updating Records
+-----------------
+
+:meth:`~pyairtable.Table.update`
+
+Updates a single record for the provided ``record_id`` using a
+dictionary representing the table's fields.
+
+.. code-block:: python
+
+  >>> table.update('recwPQIfs4wKPyc9D', {"Age": 21})
+  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {"Name": "John", "Age": 21}}, ...]
+
+
+:meth:`~pyairtable.Table.batch_update`
+
+Update multiple records from a list of :class:`~pyairtable.api.types.UpdateRecordDict`.
+
+.. code-block:: python
+
+  >>> table.batch_update([{"id": "recwPQIfs4wKPyc9D", "fields": {"Name": "Matt"}}, ...])
+  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {"Name": "Matt", ...}}, ...]
+
+
+:meth:`~pyairtable.Table.batch_upsert`
+
+.. versionadded:: 1.5.0
+
+Batch upsert (create or update) records from a list of records. For details on the behavior
+of this Airtable API endpoint, see `Update multiple records`_.
+
+.. code-block:: python
+
+  >>> table.batch_upsert(
+  ...     [{"id": "recwPQIfs4wKPyc9D", "fields": {"Name": "Matt"}}, ...],
+  ...     key_fields=["Name"]
+  ... )
+  [{'id': 'recwPQIfs4wKPyc9D', 'fields': {'Name': 'Matt', ...}}, ...]
+
+
+Deleting Records
+-----------------
+
+:meth:`~pyairtable.Table.delete`
+
+Deletes a single record using the provided ``record_id``.
+
+.. code-block:: python
+
+  >>> table.delete('recwPQIfs4wKPyc9D')
+  {'deleted': True, 'id': 'recwPQIfs4wKPyc9D'}
+
+:meth:`~pyairtable.Table.batch_delete`
+
+Batch delete records using a list of record ids.
+
+.. code-block:: python
+
+  >>> table.batch_delete(['recwPQIfs4wKPyc9D', 'recwAcQdqwe21asdf'])
+  [{'deleted': True, 'id': 'recwPQIfs4wKPyc9D'},
+   {'deleted': True, 'id': 'recwAcQdqwe21asdf'}]
+
+
+Return Values
+-------------
+
+This library will return records as :class:`~pyairtable.api.types.RecordDict`.
+
+.. code-block:: python
+
+  >>> table.all()
+  [
+      {
+          'id': 'recwPQIfs4wKPyc9D',
+          'createdTime': '2017-03-14T22:04:31.000Z'
+          'fields': {
+              'Name': 'Alice',
+          },
+      },
+      {
+          'id': 'rechOLltN9SpPHq5o',
+          'createdTime': '2017-03-20T15:21:50.000Z'
+          'fields': {
+              'Name': 'Bob',
+          },
+      },
+      {
+          'id': 'rec5eR7IzKSAOBHCz',
+          'createdTime': '2017-08-05T21:47:52.000Z'
+          'fields': {
+              'Name': 'Carol',
+          },
+      }
+  ]
diff --git a/pyairtable/api/params.py b/pyairtable/api/params.py
index 166950d..80b3ac5 100644
--- a/pyairtable/api/params.py
+++ b/pyairtable/api/params.py
@@ -40,9 +40,7 @@ def dict_list_to_request_params(
 
 
 def field_names_to_sorting_dict(field_names: List[str]) -> List[Dict[str, str]]:
-    # TODO edge case fields starting with '-'
     """
-
     >>> field_names_to_sorting_dict(["Name", "-Age"])
     [
         { "field": "FieldOne", "direction": "asc"},
diff --git a/pyairtable/api/retrying.py b/pyairtable/api/retrying.py
index 326bd2c..43f7871 100644
--- a/pyairtable/api/retrying.py
+++ b/pyairtable/api/retrying.py
@@ -19,7 +19,24 @@ def retry_strategy(
 ) -> Retry:
     """
     Creates a `Retry <https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry>`_
-    instance with adjustable default values.
+    instance with adjustable default values. :class:`~pyairtable.Api` accepts this via the
+    ``retry_strategy=`` parameter.
+
+    For example, to increase the total number of retries:
+
+        >>> from pyairtable import Api, retry_strategy
+        >>> api = Api('auth_token', retry_strategy=retry_strategy(total=10))
+
+    Or to retry certain types of server errors in addition to rate limiting errors:
+
+        >>> from pyairtable import Api, retry_strategy
+        >>> retry = retry_strategy(status_forcelist=(429, 500, 502, 503, 504))
+        >>> api = Api('auth_token', retry_strategy=retry)
+
+    You can also disable retries entirely:
+
+        >>> from pyairtable import Api
+        >>> api = Api('auth_token', retry_strategy=None)
 
     .. versionadded:: 1.4.0
 
diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py
index e5fe02e..e919383 100644
--- a/pyairtable/api/types.py
+++ b/pyairtable/api/types.py
@@ -196,6 +196,8 @@ FieldValue: TypeAlias = Union[
     List[CollaboratorEmailDict],
     FormulaErrorDict,
     FormulaNotANumberDict,
+    List[FormulaErrorDict],
+    List[FormulaNotANumberDict],
 ]
 
 
@@ -283,7 +285,8 @@ def _create_model_from_typeddict(cls: Type[T]) -> Type[pydantic.BaseModel]:
 
 def assert_typed_dict(cls: Type[T], obj: Any) -> T:
     """
-    Raises a TypeError if the given object is not a dict which conforms
+    Raises a TypeError if the given object is not a dict, or raises
+    pydantic.ValidationError if the given object does not conform
     to the interface declared by the given TypedDict.
 
     Args:
@@ -307,16 +310,19 @@ def assert_typed_dict(cls: Type[T], obj: Any) -> T:
 
         >>> assert_typed_dict(RecordDict, {"foo": "bar"})
         Traceback (most recent call last):
-        TypeError: dict with keys ['foo'] is not RecordDict
+        pydantic.error_wrappers.ValidationError: 3 validation errors for RecordDict
+        id
+          field required (type=value_error.missing)
+        createdTime
+          field required (type=value_error.missing)
+        fields
+          field required (type=value_error.missing)
     """
     if not isinstance(obj, dict):
         raise TypeError(f"expected dict, got {type(obj)}")
     # mypy complains cls isn't Hashable, but it is; see https://github.com/python/mypy/issues/2412
     model = _create_model_from_typeddict(cls)  # type: ignore
-    try:
-        model(**obj)
-    except pydantic.ValidationError:
-        raise TypeError(f"dict with keys {sorted(obj)} is not {cls.__name__}")
+    model(**obj)
     return cast(T, obj)
 
 
diff --git a/pyairtable/orm/__init__.py b/pyairtable/orm/__init__.py
index b6f03e3..b85a24f 100644
--- a/pyairtable/orm/__init__.py
+++ b/pyairtable/orm/__init__.py
@@ -1,5 +1,7 @@
+from . import fields
 from .model import Model
 
 __all__ = [
     "Model",
+    "fields",
 ]
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index f9534ff..077ae7c 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -23,37 +23,18 @@ while also providing a type-annotated interface.
         "Registered": True,
     }
 }
-
-
-Link Fields
------------
-
-In addition to standard data type fields, the :class:`LinkField` class
-offers a special behaviour that can fetch linked records, so that you can
-traverse between related records.
-
->>> from pyairtable.orm import Model, fields
->>> class Company(Model):
-...     name = fields.TextField("Name")
-...     class Meta:
-...         ...
-...
->>> class Person(Model):
-...     company = fields.LinkField("Company", Company, lazy=False)
-...     class Meta:
-...         ...
-...
->>> contact.from_id("recS6qSLw0OCA6Xul")
->>> contact.company.name # outputs value of Company.name attribute
 """
 import abc
+import importlib
 from datetime import date, datetime, timedelta
+from enum import Enum
 from typing import (
     TYPE_CHECKING,
     Any,
     ClassVar,
     Generic,
     List,
+    Literal,
     Optional,
     Tuple,
     Type,
@@ -106,6 +87,12 @@ class Field(Generic[T_API, T_ORM], metaclass=abc.ABCMeta):
     #: Whether to allow modification of the value in this field.
     readonly: bool = False
 
+    # Contains a reference to the Model class (if possible)
+    _model: Optional[Type["Model"]] = None
+
+    # The name of the attribute on the Model class (if possible)
+    _attribute_name: Optional[str] = None
+
     def __init__(
         self,
         field_name: str,
@@ -133,9 +120,27 @@ class Field(Generic[T_API, T_ORM], metaclass=abc.ABCMeta):
             self.readonly = readonly
 
     def __set_name__(self, owner: Any, name: str) -> None:
+        """
+        Called when an instance of Field is created within a class.
+        """
         self._model = owner
         self._attribute_name = name
 
+    @property
+    def _description(self) -> str:
+        """
+        Describes the field for the purpose of logging an error message.
+        Handles an edge case where a field is created directly onto a class
+        that already exists; in those cases, __set_name__ is not called.
+        """
+        if self._model and self._attribute_name:
+            return f"{self._model.__name__}.{self._attribute_name}"
+        if self._model:
+            return f"{self._model.__name__}.{self.field_name}"
+        if self.field_name:
+            return f"{self.field_name!r} field"
+        return "Field"
+
     # __get__ and __set__ are called when accessing an instance of Field on an object.
     # Model.field should return the Field instance itself, whereas
     # obj.field should return the field's value from the Model instance obj.
@@ -156,13 +161,6 @@ class Field(Generic[T_API, T_ORM], metaclass=abc.ABCMeta):
         # allow calling Model.field to get the field object instead of a value
         if not instance:
             return self
-        return self.get_value(instance)
-
-    def get_value(self, instance: "Model") -> Optional[T_ORM]:
-        """
-        Given an instance of the Model, retrieve the field value from it.
-        Easier for subclasses to override than __get__.
-        """
         try:
             return cast(T_ORM, instance._fields[self.field_name])
         except (KeyError, AttributeError):
@@ -172,14 +170,12 @@ class Field(Generic[T_API, T_ORM], metaclass=abc.ABCMeta):
         self._raise_if_readonly()
         if not hasattr(instance, "_fields"):
             instance._fields = {}
-        if self.validate_type:
+        if self.validate_type and value is not None:
             self.valid_or_raise(value)
         instance._fields[self.field_name] = value
 
     def __delete__(self, instance: "Model") -> None:
-        raise AttributeError(
-            f"cannot delete {self._model.__name__}.{self._attribute_name}"
-        )
+        raise AttributeError(f"cannot delete {self._description}")
 
     def _missing_value(self) -> Optional[T_ORM]:
         return None
@@ -198,9 +194,7 @@ class Field(Generic[T_API, T_ORM], metaclass=abc.ABCMeta):
 
     def _raise_if_readonly(self) -> None:
         if self.readonly:
-            raise AttributeError(
-                f"{self._model.__name__}.{self._attribute_name} is read-only"
-            )
+            raise AttributeError(f"{self._description} is read-only")
 
     def __repr__(self) -> str:
         args = [repr(self.field_name)]
@@ -215,26 +209,28 @@ class Field(Generic[T_API, T_ORM], metaclass=abc.ABCMeta):
 
 
 #: A generic Field whose internal and API representations are the same type.
-BasicField: TypeAlias = Field[T, T]
+_BasicField: TypeAlias = Field[T, T]
 
 
 #: An alias for any type of Field.
-AnyField: TypeAlias = BasicField[Any]
+AnyField: TypeAlias = _BasicField[Any]
 
 
-class TextField(BasicField[str]):
+class TextField(_BasicField[str]):
     """
-    Used for all Airtable text fields. Accepts `str`.
+    Used for all Airtable text fields. Accepts ``str``.
+
+    See `Single line text <https://airtable.com/developers/web/api/field-model#simpletext>`__
+    and `Long text <https://airtable.com/developers/web/api/field-model#multilinetext>`__.
     """
 
     valid_types = str
 
-    def to_internal_value(self, value: Any) -> str:
-        return str(value)
 
-
-class _NumericField(Generic[T], BasicField[T]):
-    """Base class for Number, Float, and Integer. Shares a common validation rule."""
+class _NumericField(Generic[T], _BasicField[T]):
+    """
+    Base class for Number, Float, and Integer. Shares a common validation rule.
+    """
 
     def valid_or_raise(self, value: Any) -> None:
         # Because `bool` is a subclass of `int`, we have to explicitly check for it here.
@@ -247,46 +243,43 @@ class _NumericField(Generic[T], BasicField[T]):
 
 class NumberField(_NumericField[Union[int, float]]):
     """
-    Number field with unspecified precision. Accepts either `int` or `float`.
+    Number field with unspecified precision. Accepts either ``int`` or ``float``.
+
+    See `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__.
     """
 
     valid_types = (int, float)
 
-    def to_internal_value(self, value: Any) -> Any:
-        if not isinstance(value, (float, int)):
-            raise TypeError(type(value))
-        return value
-
 
 # This cannot inherit from NumberField because valid_types would be more restrictive
 # in the subclass than what is defined in the parent class.
 class IntegerField(_NumericField[int]):
     """
-    Number field with integer precision. Accepts only `int` values.
+    Number field with integer precision. Accepts only ``int`` values.
+
+    See `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__.
     """
 
     valid_types = int
 
-    def to_internal_value(self, value: Any) -> int:
-        return int(value)
-
 
 # This cannot inherit from NumberField because valid_types would be more restrictive
 # in the subclass than what is defined in the parent class.
 class FloatField(_NumericField[float]):
     """
-    Number field with decimal precision. Accepts only `float` values.
+    Number field with decimal precision. Accepts only ``float`` values.
+
+    See `Number <https://airtable.com/developers/web/api/field-model#decimalorintegernumber>`__.
     """
 
     valid_types = float
 
-    def to_internal_value(self, value: Any) -> float:
-        return float(value)
-
 
 class RatingField(IntegerField):
     """
-    Accepts `int` values that are 1 or greater.
+    Accepts ``int`` values that are greater than zero.
+
+    See `Rating <https://airtable.com/developers/web/api/field-model#rating>`__.
     """
 
     def valid_or_raise(self, value: int) -> None:
@@ -295,9 +288,11 @@ class RatingField(IntegerField):
             raise ValueError("rating cannot be below 1")
 
 
-class CheckboxField(BasicField[bool]):
+class CheckboxField(_BasicField[bool]):
     """
-    Returns `False` instead of `None` if the field is empty on the Airtable base.
+    Returns ``False`` instead of ``None`` if the field is empty on the Airtable base.
+
+    See `Checkbox <https://airtable.com/developers/web/api/field-model#checkbox>`__.
     """
 
     valid_types = bool
@@ -305,45 +300,56 @@ class CheckboxField(BasicField[bool]):
     def _missing_value(self) -> bool:
         return False
 
-    def to_internal_value(self, value: Any) -> bool:
-        return bool(value)
-
 
 class DatetimeField(Field[str, datetime]):
     """
     DateTime field. Accepts only `datetime <https://docs.python.org/3/library/datetime.html#datetime-objects>`_ values.
+
+    See `Date and time <https://airtable.com/developers/web/api/field-model#dateandtime>`__.
     """
 
     valid_types = datetime
 
     def to_record_value(self, value: datetime) -> str:
-        """Airtable expects ISO 8601 string datetime eg. "2014-09-05T12:34:56.000Z" """
+        """
+        Converts a ``datetime`` into an ISO 8601 string, e.g. "2014-09-05T12:34:56.000Z".
+        """
         return utils.datetime_to_iso_str(value)
 
     def to_internal_value(self, value: str) -> datetime:
-        """Airtable returns ISO 8601 string datetime eg. "2014-09-05T07:00:00.000Z" """
+        """
+        Converts an ISO 8601 string, e.g. "2014-09-05T07:00:00.000Z" into a ``datetime``.
+        """
         return utils.datetime_from_iso_str(value)
 
 
 class DateField(Field[str, date]):
     """
     Date field. Accepts only `date <https://docs.python.org/3/library/datetime.html#date-objects>`_ values.
+
+    See `Date <https://airtable.com/developers/web/api/field-model#dateonly>`__.
     """
 
     valid_types = date
 
     def to_record_value(self, value: date) -> str:
-        """Airtable expects ISO 8601 date string eg. "2014-09-05"""
+        """
+        Converts a ``date`` into an ISO 8601 string, e.g. "2014-09-05".
+        """
         return utils.date_to_iso_str(value)
 
     def to_internal_value(self, value: str) -> date:
-        """Airtable returns ISO 8601 date string eg. "2014-09-05"""
+        """
+        Converts an ISO 8601 string, e.g. "2014-09-05" into a ``date``.
+        """
         return utils.date_from_iso_str(value)
 
 
 class DurationField(Field[int, timedelta]):
     """
     Duration field. Accepts only `timedelta <https://docs.python.org/3/library/datetime.html#timedelta-objects>`_ values.
+
+    See `Duration <https://airtable.com/developers/web/api/field-model#durationnumber>`__.
     Airtable's API returns this as a number of seconds.
     """
 
@@ -356,7 +362,7 @@ class DurationField(Field[int, timedelta]):
         return timedelta(seconds=value)
 
 
-class _DictField(Generic[T], BasicField[T]):
+class _DictField(Generic[T], _BasicField[T]):
     """
     Generic field type that stores a single dict. Not for use via API;
     should be subclassed by concrete field types (below).
@@ -365,123 +371,202 @@ class _DictField(Generic[T], BasicField[T]):
     valid_types = dict
 
 
-class ListField(Generic[T], Field[List[RecordId], List[T]]):
+class _ListField(Generic[T], Field[List[RecordId], List[T]]):
     """
     Generic type for a field that stores a list of values. Can be used
     to refer to a lookup field that might return more than one value.
-
-    >>> from pyairtable.orm import fields as F
-    >>> class MyTable(Model):
-    ...     class Meta:
-    ...         ...
-    ...
-    ...     lookup = F.ListField[str]("My Lookup", readonly=True)
-    ...
-    >>> rec = MyTable.first()
-    >>> rec.lookup
-    ["First value", "Second value", ...]
+    Not for use via API; should be subclassed by concrete field types (below).
     """
 
     valid_types = list
-    linked_model: Optional[Type[T]] = None
 
-    def __init__(
-        self,
-        field_name: str,
-        model: Optional[Type[T]] = None,
-        validate_type: bool = True,
-        readonly: Optional[bool] = None,
-    ):
-        """
-        Constructs a new field.
+    # List fields will always return a list, never ``None``, so we
+    # have to overload the type annotations for __get__
 
-        Args:
-            field_name: Name of the Airtable field.
-            model: Type we expect to get from the API.
-        """
-        super().__init__(field_name, validate_type=validate_type, readonly=readonly)
-        if model is not None:
-            self.linked_model = model
+    @overload
+    def __get__(self, instance: None, owner: Type[Any]) -> SelfType:
+        ...
 
-    def _repr_fields(self) -> List[Tuple[str, Any]]:
-        return [
-            ("model", self.linked_model),
-            *super()._repr_fields(),
-        ]
+    @overload
+    def __get__(self, instance: "Model", owner: Type[Any]) -> List[T]:
+        ...
+
+    def __get__(
+        self, instance: Optional["Model"], owner: Type[Any]
+    ) -> Union[SelfType, List[T]]:
+        if not instance:
+            return self
+        return self._get_list_value(instance)
 
-    def get_value(self, model: "Model") -> List[T]:
-        value = super().get_value(model)
+    def _get_list_value(self, instance: "Model") -> List[T]:
+        value = cast(List[T], instance._fields.get(self.field_name))
+        # If Airtable returns no value, substitute an empty list.
         if value is None:
             value = []
-            setattr(model, self._attribute_name, value)
+            # For implementers to be able to modify this list in place
+            # and persist it later when they call .save(), we need to
+            # set this empty list as the field's value.
+            if not self.readonly:
+                instance._fields[self.field_name] = value
         return value
 
-    def valid_or_raise(self, value: Any) -> None:
-        super().valid_or_raise(value)
-        if self.linked_model:
-            for obj in value:
-                if not isinstance(obj, self.linked_model):
-                    raise TypeError(f"expected {self.linked_model}; got {type(obj)}")
-
     def to_internal_value(self, value: Optional[List[T]]) -> List[T]:
         if value is None:
             value = []
         return value
 
 
-class LinkField(ListField[T_Linked]):
+class _ValidatingListField(Generic[T], _ListField[T]):
+    contains_type: Type[T]
+
+    def valid_or_raise(self, value: Any) -> None:
+        super().valid_or_raise(value)
+        for obj in value:
+            if not isinstance(obj, self.contains_type):
+                raise TypeError(f"expected {self.contains_type}; got {type(obj)}")
+
+
+class _LinkFieldOptions(Enum):
+    LinkSelf = object()
+
+
+#: Sentinel option for the `model=` param to :class:`~LinkField`
+LinkSelf = _LinkFieldOptions.LinkSelf
+
+
+class LinkField(_ListField[T_Linked]):
     """
-    MultipleRecordLinks field. Accepts lists of Model instances.
+    Represents a MultipleRecordLinks field. Returns and accepts lists of Models.
+
+    Can also be used with a lookup field that pulls from a MultipleRecordLinks field,
+    provided the field is created with ``readonly=True``.
 
-    See :ref:`Link Fields`.
+    See `Link to another record <https://airtable.com/developers/web/api/field-model#foreignkey>`__.
     """
 
+    _linked_model: Union[str, Literal[_LinkFieldOptions.LinkSelf], Type[T_Linked]]
+
     def __init__(
         self,
         field_name: str,
-        model: Union[str, Type[T_Linked]],
-        lazy: bool = True,
+        model: Union[str, Literal[_LinkFieldOptions.LinkSelf], Type[T_Linked]],
+        validate_type: bool = True,
+        readonly: Optional[bool] = None,
+        lazy: bool = False,
     ):
         """
         Args:
-            field_name: Name of Airtable Column
-            model: Model of Linked Type. Must be subtype of :class:`Model`
-            lazy: Use `True` to load linked model when looking up attribute. `False`
-                will create empty object with only `id` but will not fetch fields.
+            field_name: Name of the Airtable field.
+            model:
+                Model class representing the linked table. There are a few options:
+
+                1. You can provide a ``str`` that is the fully qualified module and class name.
+                   For example, ``"your.module.Model"`` will import ``Model`` from ``your.module``.
+                2. You can provide a ``str`` that is *just* the class name, and it will be imported
+                   from the same module as the model class.
+                3. You can provide the sentinel value :data:`~LinkSelf`, and the link field
+                   will point to the same model where the link field is created.
+
+            validate_type: Whether to raise a TypeError if attempting to write
+                an object of an unsupported type as a field value. If ``False``, you
+                may encounter unpredictable behavior from the Airtable API.
+            readonly: If ``True``, any attempt to write a value to this field will
+                raise an ``AttributeError``. This will not, however, prevent any
+                modification of the list object returned by this field.
+            lazy: If ``True``, this field will return empty objects with oly IDs;
+                call :meth:`~pyairtable.orm.Model.fetch` to retrieve values.
         """
-        if isinstance(model, str):
-            raise NotImplementedError("path import not implemented")
-            # https://github.com/FactoryBoy/factory_boy/blob/37f962720814dff42d7a6a848ccfd200fc7f5ae2/factory/declarations.py#L339
-            # model = cast(Type[T_Linked], locate(model))
+        from pyairtable.orm import Model  # noqa, avoid circular import
 
-        if not hasattr(model, "get_table"):
-            raise TypeError(f"{type(model)} does not appear to subclass orm.Model")
+        if not (
+            model is _LinkFieldOptions.LinkSelf
+            or isinstance(model, str)
+            or (isinstance(model, type) and issubclass(model, Model))
+        ):
+            raise TypeError(f"expected str, Model, or LinkSelf; got {type(model)}")
 
+        super().__init__(field_name, validate_type=validate_type, readonly=readonly)
+        self._linked_model = model
         self._lazy = lazy
-        super().__init__(field_name, model=model)
+
+    @property
+    def linked_model(self) -> Type[T_Linked]:
+        # This avoids resolving the model name into a class until we need it
+        if isinstance(self._linked_model, str):
+            modpath, _, clsname = self._linked_model.rpartition(".")
+            if not modpath:
+                if self._model is None:
+                    raise RuntimeError(f"{self._description} not created on a Model")
+                modpath = self._model.__module__
+            mod = importlib.import_module(modpath)
+            cls = getattr(mod, clsname)
+            self._linked_model = cast(Type[T_Linked], cls)
+
+        if self._linked_model is _LinkFieldOptions.LinkSelf:
+            if self._model is None:
+                raise RuntimeError(f"{self._description} not created on a Model")
+            self._linked_model = cast(Type[T_Linked], self._model)
+
+        return self._linked_model
 
     def _repr_fields(self) -> List[Tuple[str, Any]]:
         return [
-            ("model", self.linked_model),
+            ("model", self._linked_model),
+            ("validate_type", self.validate_type),
+            ("readonly", self.readonly),
             ("lazy", self._lazy),
         ]
 
-    def to_internal_value(self, value: Any) -> List[T_Linked]:
-        # If Lazy, create empty from model class and set id
-        # If not Lazy, fetch record from pyairtable and create new model instance
-        if not self.linked_model:
-            raise RuntimeError(f"{self.__class__.__name__} must be declared with model")
-        should_fetch = not self._lazy
-        linked_models = [
-            cast(T_Linked, self.linked_model._linked_cache.get(id_))
-            or self.linked_model.from_id(id_, fetch=should_fetch)
-            for id_ in value
+    # Unlike most other field classes, LinkField does not store its
+    # internal representation (T_ORM) in instance._fields at first;
+    # we defer object creation to the first time they're requested,
+    # so we can avoid infinite recursion during to_internal_value().
+    def _get_list_value(self, instance: "Model") -> List[T_Linked]:
+        if not (records := super()._get_list_value(instance)):
+            return records
+        # If there are any values which are IDs rather than instances,
+        # retrieve their values in bulk, and store them keyed by ID
+        # so we can maintain the order we received from the API.
+        new_records = {}
+        if new_record_ids := [v for v in records if isinstance(v, RecordId)]:
+            new_records = {
+                record.id: record
+                for record in self.linked_model.from_ids(
+                    cast(List[RecordId], new_record_ids)
+                )
+            }
+        # If the list contains record IDs, replace the contents with instances.
+        # Other code may already have references to this specific list, so
+        # we replace the existing list's values.
+        records[:] = [
+            new_records[cast(RecordId, value)] if isinstance(value, RecordId) else value
+            for value in records
         ]
-        self.linked_model._linked_cache.update({m.id: m for m in linked_models})
-        return linked_models
+        return records
+
+    def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]:
+        if not value:
+            return []
+        # If the _fields value contains str, it means we loaded it from the API
+        # but we never actually accessed the value (see _get_value below).
+        # When persisting this model back to the API, we can just write those IDs.
+        if all(isinstance(v, str) for v in value):
+            return cast(List[str], value)
+        # From here on, we assume we're dealing with models, not record IDs.
+        records = cast(List[T_Linked], value)
+        self.valid_or_raise(records)
+        # We could *try* to recursively save models that don't have an ID yet,
+        # but that requires us to second-guess the implementers' intentions.
+        # Better to just raise an exception.
+        if not all(record.exists() for record in records):
+            raise ValueError(f"{self._description} contains an unsaved record")
+        return [record.id for record in records]
 
-    def to_record_value(self, value: Any) -> List[str]:
-        return [v.id for v in value]
+    def valid_or_raise(self, value: Any) -> None:
+        super().valid_or_raise(value)
+        for obj in value:
+            if not isinstance(obj, self.linked_model):
+                raise TypeError(f"expected {self.linked_model}; got {type(obj)}")
 
 
 # Many of these are "passthrough" subclasses for now. E.g. there is no real
@@ -494,7 +579,9 @@ class LinkField(ListField[T_Linked]):
 
 class AutoNumberField(IntegerField):
     """
-    Equivalent to ``IntegerField(readonly=True)``
+    Equivalent to :class:`IntegerField(readonly=True) <IntegerField>`.
+
+    See `Auto number <https://airtable.com/developers/web/api/field-model#autonumber>`__.
     """
 
     readonly = True
@@ -528,7 +615,9 @@ class CollaboratorField(_DictField[CollaboratorDict]):
 
 class CountField(IntegerField):
     """
-    Equivalent to ``IntegerField(readonly=True)``
+    Equivalent to :class:`IntegerField(readonly=True) <IntegerField>`.
+
+    See `Count <https://airtable.com/developers/web/api/field-model#count>`__.
     """
 
     readonly = True
@@ -536,7 +625,9 @@ class CountField(IntegerField):
 
 class CreatedByField(CollaboratorField):
     """
-    Equivalent to ``CollaboratorField(readonly=True)``
+    Equivalent to :class:`CollaboratorField(readonly=True) <CollaboratorField>`.
+
+    See `Created by <https://airtable.com/developers/web/api/field-model#createdby>`__.
     """
 
     readonly = True
@@ -544,7 +635,9 @@ class CreatedByField(CollaboratorField):
 
 class CreatedTimeField(DatetimeField):
     """
-    Equivalent to ``DatetimeField(readonly=True)``
+    Equivalent to :class:`DatetimeField(readonly=True) <DatetimeField>`.
+
+    See `Created time <https://airtable.com/developers/web/api/field-model#createdtime>`__.
     """
 
     readonly = True
@@ -552,19 +645,25 @@ class CreatedTimeField(DatetimeField):
 
 class CurrencyField(NumberField):
     """
-    Equivalent to ``NumberField``
+    Equivalent to :class:`~NumberField`.
+
+    See `Currency <https://airtable.com/developers/web/api/field-model#currencynumber>`__.
     """
 
 
 class EmailField(TextField):
     """
-    Equivalent to ``TextField``.
+    Equivalent to :class:`~TextField`.
+
+    See `Email <https://airtable.com/developers/web/api/field-model#email>`__.
     """
 
 
 class ExternalSyncSourceField(TextField):
     """
-    Equivalent to ``TextField(readonly=True)``
+    Equivalent to :class:`TextField(readonly=True) <TextField>`.
+
+    See `Sync source <https://airtable.com/developers/web/api/field-model#syncsource>`__.
     """
 
     readonly = True
@@ -572,7 +671,9 @@ class ExternalSyncSourceField(TextField):
 
 class LastModifiedByField(CollaboratorField):
     """
-    Equivalent to ``CollaboratorField(readonly=True)``
+    Equivalent to :class:`CollaboratorField(readonly=True) <CollaboratorField>`.
+
+    See `Last modified by <https://airtable.com/developers/web/api/field-model#lastmodifiedby>`__.
     """
 
     readonly = True
@@ -580,101 +681,133 @@ class LastModifiedByField(CollaboratorField):
 
 class LastModifiedTimeField(DatetimeField):
     """
-    Equivalent to ``DatetimeField(readonly=True)``
+    Equivalent to :class:`DatetimeField(readonly=True) <DatetimeField>`.
+
+    See `Last modified time <https://airtable.com/developers/web/api/field-model#lastmodifiedtime>`__.
     """
 
     readonly = True
 
 
-# TODO: LookupField actually needs to support other types besides str.
-class LookupField(ListField[str]):
+class LookupField(Generic[T], _ListField[T]):
     """
-    Equivalent to ``ListField[str]``.
+    Generic field class for a lookup, which returns a list of values.
+
+    pyAirtable does not inspect field configuration at runtime or during type checking.
+    If you use mypy, you can declare which type(s) the lookup returns:
+
+    >>> from pyairtable.orm import fields as F
+    >>> class MyTable(Model):
+    ...     Meta = fake_meta()
+    ...     lookup = F.LookupField[str]("My Lookup")
+    ...
+    >>> rec = MyTable.first()
+    >>> rec.lookup
+    ["First value", "Second value", ...]
+
+    See `Lookup <https://airtable.com/developers/web/api/field-model#lookup>`__.
     """
 
-    linked_model = str
+    readonly = True
 
 
-class MultipleAttachmentsField(ListField[AttachmentDict]):
+class MultipleAttachmentsField(_ValidatingListField[AttachmentDict]):
     """
-    Accepts a list of dicts that should conform to the format detailed in the
-    `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`_
-    documentation.
+    Accepts a list of dicts in the format detailed in
+    `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`_.
     """
 
-    linked_model = cast(Type[AttachmentDict], dict)
+    contains_type = cast(Type[AttachmentDict], dict)
 
 
-class MultipleCollaboratorsField(ListField[CollaboratorDict]):
+class MultipleCollaboratorsField(_ValidatingListField[CollaboratorDict]):
     """
-    Accepts a list of dicts that should conform to the format detailed in the
-    `Multiple Collaborators <https://airtable.com/developers/web/api/field-model#multicollaborator>`_
-    documentation.
+    Accepts a list of dicts in the format detailed in
+    `Multiple Collaborators <https://airtable.com/developers/web/api/field-model#multicollaborator>`_.
     """
 
-    linked_model = cast(Type[CollaboratorDict], dict)
+    contains_type = cast(Type[CollaboratorDict], dict)
 
 
-class MultipleSelectField(ListField[str]):
+class MultipleSelectField(_ValidatingListField[str]):
     """
     Accepts a list of ``str``.
+
+    See `Multiple select <https://airtable.com/developers/web/api/field-model#multiselect>`__.
     """
 
-    linked_model = str
+    contains_type = str
 
 
 class PercentField(NumberField):
     """
-    Equivalent to ``NumberField``
+    Equivalent to :class:`~NumberField`.
+
+    See `Percent <https://airtable.com/developers/web/api/field-model#percentnumber>`__.
     """
 
 
 class PhoneNumberField(TextField):
     """
-    Equivalent to ``TextField``
+    Equivalent to :class:`~TextField`.
+
+    See `Phone <https://airtable.com/developers/web/api/field-model#phone>`__.
     """
 
 
 class RichTextField(TextField):
     """
-    Equivalent to ``TextField``
+    Equivalent to :class:`~TextField`.
+
+    See `Rich text <https://airtable.com/developers/web/api/field-model#rich-text>`__.
     """
 
 
 class SelectField(TextField):
     """
-    Equivalent to ``TextField``
+    Equivalent to :class:`~TextField`.
+
+    See `Single select <https://airtable.com/developers/web/api/field-model#select>`__.
     """
 
 
 class UrlField(TextField):
     """
-    Equivalent to ``TextField``
+    Equivalent to :class:`~TextField`.
+
+    See `Url <https://airtable.com/developers/web/api/field-model#urltext>`__.
     """
 
 
+#: Set of all Field subclasses exposed by the library.
+#:
+#: :meta hide-value:
 ALL_FIELDS = {
     field_class
     for name, field_class in vars().items()
     if isinstance(field_class, type)
     and issubclass(field_class, Field)
+    and field_class is not Field
     and not name.startswith("_")
 }
-"""
-Set of all Field subclasses exposed by the library.
-
-:meta hide-value:
-"""
 
 
+#: Set of all read-only Field subclasses exposed by the library.
+#:
+#: :meta hide-value:
 READONLY_FIELDS = {cls for cls in ALL_FIELDS if cls.readonly}
-"""
-Set of all read-only Field subclasses exposed by the library.
-
-:meta hide-value:
-"""
 
 
+#: Mapping of Airtable field type names to their ORM classes.
+#: See https://airtable.com/developers/web/api/field-model
+#: and :ref:`Formulas, Rollups, and Lookups`.
+#:
+#: The data type of "formula" and "rollup" fields will depend
+#: on the underlying fields they reference, so it is not practical
+#: for the ORM to know or detect those fields' types. These two
+#: field type names are mapped to the constant ``NotImplemented``.
+#:
+#: :meta hide-value:
 FIELD_TYPES_TO_CLASSES = {
     "autoNumber": AutoNumberField,
     "barcode": BarcodeField,
@@ -709,29 +842,12 @@ FIELD_TYPES_TO_CLASSES = {
     "singleSelect": SelectField,
     "url": UrlField,
 }
-"""
-Mapping of Airtable field type names to their ORM classes.
-See https://airtable.com/developers/web/api/field-model
-
-The data type of "formula" and "rollup" fields will depend
-on the underlying fields they reference, so it is not practical
-for the ORM to know or detect those fields' types. These two
-field type names are mapped to the constant ``NotImplemented``.
-
-If you need to refer to a formula or rollup field in the ORM,
-you need to know what type of value you expect it to contain.
-You can then declare that as a read-only field:
-
-.. code-block:: python
 
-    from pyairtable.orm import fields as F
 
-    class MyTable(Model):
-        class Meta:
-            ...
-
-        formula_field = F.TextField("My Formula", readonly=True)
-        rollup_field = F.IntegerField("Row Count", readonly=True)
-
-:meta hide-value:
-"""
+#: Mapping of field classes to the set of supported Airtable field types.
+#:
+#: :meta hide-value:
+FIELD_CLASSES_TO_TYPES = {
+    cls: {key for (key, val) in FIELD_TYPES_TO_CLASSES.items() if val == cls}
+    for cls in ALL_FIELDS
+}
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index bc754ba..fdce40e 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -1,75 +1,5 @@
-"""
-The :class:`Model` class allows you create an orm-style class for your
-Airtable tables.
-
->>> from pyairtable.orm import Model, fields
->>> class Contact(Model):
-...     first_name = fields.TextField("First Name")
-...     last_name = fields.TextField("Last Name")
-...     email = fields.EmailField("Email")
-...     is_registered = fields.CheckboxField("Registered")
-...     company = fields.LinkField("Company", Company, lazy=False)
-...
-...     class Meta:
-...         base_id = "appaPqizdsNHDvlEm"
-...         table_name = "Contact"
-...         api_key = "keyapikey"
-
-
-Once you have a class, you can create new objects to represent your
-Airtable records. Call :meth:`~pyairtable.orm.model.Model.save` to create a new record.
-
->>> contact = Contact(
-...     first_name="Mike",
-...     last_name="McDonalds",
-...     email="mike@mcd.com",
-...     is_registered=False
-... )
-...
->>> assert contact.id is None
->>> contact.exists()
-False
->>> assert contact.save()
->>> contact.exists()
-True
->>> contact.id
-'rec123asa23'
-
-
-You can read and modify attributes. If record already exists,
-:meth:`~pyairtable.orm.model.Model.save` will update the record:
-
->>> assert contact.is_registered is False
->>> contact.save()
->>> assert contact.is_registered is True
->>> contact.to_record()
-{
-    "id": "recS6qSLw0OCA6Xul",
-    "createdTime": "2021-07-14T06:42:37.000Z",
-    "fields": {
-        "First Name": "Mike",
-        "Last Name": "McDonalds",
-        "Email": "mike@mcd.com",
-        "Registered": True
-    }
-}
-
-You can use :meth:`~pyairtable.orm.model.Model.delete` to delete the record:
-
->>> contact.delete()
-True
-
-You can also use :meth:`~pyairtable.orm.model.Model.batch_save` and
-:meth:`~pyairtable.orm.model.Model.batch_delete` on several records at once:
-
->>> contacts = Contact.all()
->>> contacts.append(Contact(first_name="Alice", email="alice@example.com"))
->>> Contact.batch_save(contacts)
->>> Contact.batch_delete(contacts)
-"""
-import abc
 from functools import lru_cache
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Iterable, List, Optional
 
 from typing_extensions import Self as SelfType
 
@@ -83,10 +13,11 @@ from pyairtable.api.types import (
     RecordId,
     UpdateRecordDict,
 )
+from pyairtable.formulas import OR, STR_VALUE
 from pyairtable.orm.fields import AnyField, Field
 
 
-class Model(metaclass=abc.ABCMeta):
+class Model:
     """
     This class allows you create an orm-style class for your Airtable tables.
 
@@ -111,13 +42,17 @@ class Model(metaclass=abc.ABCMeta):
     id: str = ""
     created_time: str = ""
     _deleted: bool = False
-    _fields: Dict[FieldName, Any] = {}
-    _linked_cache: Dict[RecordId, SelfType] = {}
+    _fields: Dict[FieldName, Any]
 
     def __init_subclass__(cls, **kwargs: Any):
         cls._validate_class()
         super().__init_subclass__(**kwargs)
 
+    def __repr__(self) -> str:
+        if not self.id:
+            return f"<unsaved {self.__class__.__name__}>"
+        return f"<{self.__class__.__name__} id={self.id!r}>"
+
     @classmethod
     def _attribute_descriptor_map(cls) -> Dict[str, AnyField]:
         """
@@ -170,10 +105,13 @@ class Model(metaclass=abc.ABCMeta):
         return {v.field_name: k for k, v in cls._attribute_descriptor_map().items()}
 
     def __init__(self, **fields: Any):
-        # To Store Fields
+        if "id" in fields:
+            self.id = fields.pop("id")
+
+        # Field values in internal (not API) representation
         self._fields = {}
 
-        # Set descriptors
+        # Call __set__ on each field to set field values
         for key, value in fields.items():
             if key not in self._attribute_descriptor_map():
                 raise AttributeError(key)
@@ -296,7 +234,7 @@ class Model(metaclass=abc.ABCMeta):
         """
         map_ = self._field_name_descriptor_map()
         fields = {
-            field: map_[field].to_record_value(value)
+            field: None if value is None else map_[field].to_record_value(value)
             for field, value in self._fields.items()
             if not (map_[field].readonly and only_writable)
         }
@@ -304,7 +242,12 @@ class Model(metaclass=abc.ABCMeta):
 
     @classmethod
     def from_record(cls, record: RecordDict) -> SelfType:
-        """Create instance from record dictionary"""
+        """
+        Create instance from record dict
+
+        Args:
+            record: The dict returned from the Airtable API.
+        """
         name_field_map = cls._field_name_descriptor_map()
         # Convert Column Names into model field names
         field_values = {
@@ -315,44 +258,71 @@ class Model(metaclass=abc.ABCMeta):
             if field in name_field_map
         }
         # Since instance(**field_values) will perform validation and fail on
-        # any readonly fields, so instead we directly set instance._fields.
-        instance = cls()
+        # any readonly fields, instead we directly set instance._fields.
+        instance = cls(id=record["id"])
         instance._fields = field_values
         instance.created_time = record["createdTime"]
-        instance.id = record["id"]
         return instance
 
     @classmethod
-    def from_id(cls, record_id: str, fetch: bool = True) -> SelfType:
+    def from_id(
+        cls,
+        record_id: RecordId,
+        fetch: bool = True,
+    ) -> SelfType:
         """
         Create an instance from a `record_id`
 
         Args:
             record_id: |arg_record_id|
-            fetch: If `True`, record will be fetched and fields will be
-                updated. If `False`, a new instance is created with the provided `id`,
-                but field values are unset. Default is `True`.
+            fetch: If ``True``, record will be fetched and field values will be
+                updated. If ``False``, a new instance is created with the provided ID,
+                but field values are unset.
         """
+        instance = cls(id=record_id)
         if fetch:
-            table = cls.get_table()
-            record = table.get(record_id)
-            return cls.from_record(record)
-        else:
-            instance = cls()
-            instance.id = record_id
-            return instance
+            instance.fetch()
+        return instance
 
     def fetch(self) -> None:
         """Fetches field and resets instance field values from the Airtable record"""
         if not self.id:
             raise ValueError("cannot be fetched because instance does not have an id")
 
-        updated = self.from_id(self.id, fetch=True)
-        self._fields = updated._fields
-        self.created_time = updated.created_time
+        record = self.get_table().get(self.id)
+        unused = self.from_record(record)
+        self._fields = unused._fields
+        self.created_time = unused.created_time
 
-    def __repr__(self) -> str:
-        return "<Model={} {}>".format(self.__class__.__name__, hex(id(self)))
+    @classmethod
+    def from_ids(
+        cls,
+        record_ids: Iterable[RecordId],
+        fetch: bool = True,
+    ) -> List[SelfType]:
+        """
+        Create a list of instances from record IDs. If any record IDs returned
+        are invalid this will raise a KeyError, but only *after* retrieving all
+        other valid records from the API.
+
+        Args:
+            record_ids: |arg_record_id|
+            fetch: If ``True``, records will be fetched and field values will be
+                updated. If ``False``, new instances are created with the provided IDs,
+                but field values are unset.
+        """
+        record_ids = list(record_ids)
+        if not fetch:
+            return [cls.from_id(record_id, fetch=False) for record_id in record_ids]
+        formula = OR(
+            *[f"RECORD_ID()={STR_VALUE(record_id)}" for record_id in record_ids]
+        )
+        records = [
+            cls.from_record(record) for record in cls.get_table().all(formula=formula)
+        ]
+        records_by_id = {record.id: record for record in records}
+        # Ensure we return records in the same order, and raise KeyError if any are missing
+        return [records_by_id[record_id] for record_id in record_ids]
 
     @classmethod
     def batch_save(cls, models: List[SelfType]) -> None:
diff --git a/requirements-dev.txt b/requirements-dev.txt
index f4b3baa..c57114a 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,4 +1,5 @@
 # Docs
+cogapp
 Sphinx==4.5.0
 sphinx-autoapi
 sphinxext-opengraph
diff --git a/tox.ini b/tox.ini
index 91281d7..80863b7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -40,6 +40,7 @@ basepython = python3.8
 deps =
     -r requirements-dev.txt
 commands =
+    python -m cogapp -r --verbosity=1 {toxinidir}/docs/source/*.rst
     python -m sphinx -a -W -E {toxinidir}/docs/source {toxinidir}/docs/build
 
 [pytest]


Test patch:
diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py
index c3bca80..ac82855 100644
--- a/tests/integration/test_integration_orm.py
+++ b/tests/integration/test_integration_orm.py
@@ -4,43 +4,105 @@ import pytest
 
 from pyairtable.orm import Model
 from pyairtable.orm import fields as f
+from pyairtable.testing import fake_meta
 
 pytestmark = [pytest.mark.integration]
 
 
-@pytest.fixture
-def Address(make_meta):
-    class _Address(Model):
-        Meta = make_meta("Address")
-        street = f.TextField("Street")
+class _Address(Model):
+    Meta = fake_meta()
+
+    number = f.IntegerField("Number")
+    street = f.TextField("Street")
+    contact = f.LinkField["_Contact"](
+        "Contact",
+        model="test_integration_orm._Contact",
+        lazy=True,
+    )
 
-    yield _Address
 
-    table = _Address.get_table()
-    records = table.all()
-    table.batch_delete([r["id"] for r in records])
+class _Contact(Model):
+    Meta = fake_meta()
+
+    first_name = f.TextField("First Name")
+    last_name = f.TextField("Last Name")
+    email = f.EmailField("Email")
+    is_registered = f.CheckboxField("Registered")
+    address = f.LinkField("Address", _Address, lazy=True)
+    birthday = f.DateField("Birthday")
+    last_access = f.DatetimeField("Last Access")
+
+
+class _Everything(Model):
+    Meta = fake_meta()
+
+    name = f.TextField("Name")
+    notes = f.RichTextField("Notes")
+    assignee = f.CollaboratorField("Assignee")
+    watchers = f.MultipleCollaboratorsField("Watchers")
+    status = f.SelectField("Status")
+    attachments = f.MultipleAttachmentsField("Attachments")
+    done = f.CheckboxField("Done")
+    tags = f.MultipleSelectField("Tags")
+    date = f.DateField("Date")
+    datetime = f.DatetimeField("DateTime")
+    duration_hmm = f.DurationField("Duration (h:mm)")
+    duration_hmmsss = f.DurationField("Duration (h:mm:ss.s)")
+    phone = f.PhoneNumberField("Phone")
+    email = f.EmailField("Email")
+    url = f.UrlField("URL")
+    integer = f.IntegerField("Integer")
+    decimal = f.FloatField("Decimal 1")
+    number = f.NumberField("Decimal 2")
+    autonumber = f.AutoNumberField("Autonumber")
+    dollars = f.CurrencyField("Dollars")
+    percent = f.PercentField("Percent")
+    stars = f.RatingField("Stars")
+    barcode = f.BarcodeField("Barcode")
+    button = f.ButtonField("Open URL")
+    formula_integer = f.IntegerField("Formula Int", readonly=True)
+    formula_float = f.FloatField("Formula Float", readonly=True)
+    formula_text = f.TextField("Formula Text", readonly=True)
+    formula_error = f.TextField("Formula Error", readonly=True)
+    formula_nan = f.TextField("Formula NaN", readonly=True)
+    addresses = f.LinkField("Address", _Address)
+    link_count = f.CountField("Link to Self (Count)")
+    link_self = f.LinkField["_Everything"](
+        "Link to Self",
+        model="test_integration_orm._Everything",
+        lazy=False,
+    )
+    rollup_integer = f.IntegerField("Rollup Int", readonly=True)
+    rollup_error = f.TextField("Rollup Error", readonly=True)
+    lookup_integer = f.LookupField("Lookup Int", readonly=True)
+    lookup_error = f.LookupField("Lookup Error", readonly=True)
+    created = f.CreatedTimeField("Created")
+    created_by = f.CreatedByField("Created By")
+    last_modified = f.LastModifiedTimeField("Last Modified")
+    last_modified_by = f.LastModifiedByField("Last Modified By")
+
+
+def _model_fixture(cls, monkeypatch, make_meta):
+    monkeypatch.setattr(cls, "Meta", make_meta(cls.__name__.replace("_", "")))
+    yield cls
+    table = cls.get_table()
+    for page in table.iterate():
+        table.batch_delete([record["id"] for record in page])
 
 
 @pytest.fixture
-def Contact(Address, make_meta):
-    class _Contact(Model):
-        Meta = make_meta("Contact")
-        first_name = f.TextField("First Name")
-        last_name = f.TextField("Last Name")
-        email = f.EmailField("Email")
-        is_registered = f.CheckboxField("Registered")
-        address = f.LinkField("Address", Address, lazy=True)
-        birthday = f.DateField("Birthday")
-        last_access = f.DatetimeField("Last Access")
+def Everything(monkeypatch, make_meta):
+    yield from _model_fixture(_Everything, monkeypatch, make_meta)
 
-    # HACK - back-ref test
-    Address.contact = f.LinkField("Contact", _Contact, lazy=True)
 
-    yield _Contact
+@pytest.fixture
+def Address(monkeypatch, make_meta):
+    yield from _model_fixture(_Address, monkeypatch, make_meta)
+
 
-    table = _Contact.get_table()
-    records = table.all()
-    table.batch_delete([r["id"] for r in records])
+@pytest.fixture
+def Contact(monkeypatch, make_meta):
+    yield from _model_fixture(_Contact, monkeypatch, make_meta)
 
 
 def test_integration_orm(Contact, Address):
@@ -107,3 +169,63 @@ def test_undeclared_fields(make_meta):
     # This should not clobber the values in 'Email' or 'Birthday'
     contact.save()
     assert table.get(record["id"]) == record
+
+
+def test_every_field(Everything):
+    """
+    Integration test for the ORM that exercises every supported field type.
+    """
+    # Validate there are no field types we skipped
+    classes_used = {
+        type(field) for field in vars(Everything).values() if isinstance(field, f.Field)
+    }
+    for field_class in f.ALL_FIELDS:
+        if field_class in {f.ExternalSyncSourceField}:
+            continue
+        assert field_class in classes_used
+
+    record: _Everything = Everything(
+        name=None,
+        notes=None,
+        assignee=None,
+        status=None,
+        attachments=None,
+        done=None,
+        tags=None,
+        date=None,
+        datetime=None,
+        duration_hmm=None,
+        duration_hmmsss=None,
+        phone=None,
+        email=None,
+        url=None,
+        integer=None,
+        decimal=None,
+        number=None,
+        dollars=None,
+        percent=None,
+        stars=None,
+        barcode=None,
+        addresses=None,
+        link_self=None,
+    )
+    assert not record.id
+    record.save()
+    assert record.id
+    assert record.addresses == []
+    assert record.link_self == []
+    record.link_self = [record]
+    record.save()
+
+    # The ORM won't refresh the model's field values after save()
+    assert record.formula_integer is None
+    assert record.formula_nan is None
+    assert record.link_count is None
+    assert record.lookup_error == []
+    assert record.lookup_integer == []
+    record.fetch()
+    assert record.formula_integer is not None
+    assert record.formula_nan == {"specialValue": "NaN"}
+    assert record.link_count == 1
+    assert record.lookup_error == [{"error": "#ERROR!"}]
+    assert record.lookup_integer == [record.formula_integer]
diff --git a/tests/test_api_retrying.py b/tests/test_api_retrying.py
index e0e3e29..46e6ad5 100644
--- a/tests/test_api_retrying.py
+++ b/tests/test_api_retrying.py
@@ -220,7 +220,6 @@ def test_retry_during_iterate(table_with_retry_strategy, mock_endpoint):
     Test that our default retry logic will be enough to get through several pages of data.
     Relies on ``mock_endpoint`` to return 429s whenever QPS goes over the limit.
     """
-    # TODO: remove `time.sleep` everywhere and make retry_strategy() the default.
     table = table_with_retry_strategy(retry_strategy())
 
     page_count = 10
diff --git a/tests/test_api_types.py b/tests/test_api_types.py
index 00c0130..35ddb6f 100644
--- a/tests/test_api_types.py
+++ b/tests/test_api_types.py
@@ -1,4 +1,5 @@
 import pytest
+from pydantic import ValidationError
 
 from pyairtable.api import types as T
 from pyairtable.testing import fake_attachment, fake_id, fake_record, fake_user
@@ -29,7 +30,7 @@ def test_assert_typed_dict(cls, value):
     """
     T.assert_typed_dict(cls, value)
     T.assert_typed_dicts(cls, [value])
-    # Test that a mix of valid and invalid values still raises TypeError
+    # Test that a mix of valid and invalid values still raises an exception
     with pytest.raises(TypeError):
         T.assert_typed_dicts(cls, [value, -1])
 
@@ -46,21 +47,26 @@ def test_assert_typed_dict(cls, value):
         (T.RecordDeletedDict, {}),
         (T.RecordDict, {}),
         (T.UpdateRecordDict, {}),
-        # test failure for other data types
-        (T.RecordDict, -1),
     ],
 )
 def test_assert_not_typed_dict(cls, value):
     """
     Test that we can assert that a dict does *not* conform to a TypedDict.
     """
-    with pytest.raises(TypeError):
+    with pytest.raises(ValidationError):
         T.assert_typed_dict(cls, value)
-    with pytest.raises(TypeError):
+    with pytest.raises(ValidationError):
         T.assert_typed_dicts(cls, [value])
 
 
-def test_assert_typed_dicts__not_list():
+def test_assert_typed_dict__wrong_type():
+    # assert_typed_dict() with a non-dict
+    with pytest.raises(TypeError):
+        T.assert_typed_dict(T.RecordDict, -1)
+    # assert_typed_dicts() with a list of non-dict objects
+    with pytest.raises(TypeError):
+        T.assert_typed_dicts(T.RecordDict, [-1])
+    # assert_typed_dicts() with a non-list
     with pytest.raises(TypeError):
         T.assert_typed_dicts(T.RecordDict, object())
 
diff --git a/tests/test_orm.py b/tests/test_orm.py
index befcc2a..b012859 100644
--- a/tests/test_orm.py
+++ b/tests/test_orm.py
@@ -1,3 +1,4 @@
+import re
 from datetime import datetime
 from operator import itemgetter
 from unittest import mock
@@ -11,51 +12,6 @@ from pyairtable.orm import fields as f
 from pyairtable.testing import fake_meta, fake_record
 
 
-def test_model_missing_meta():
-    """
-    Test that we throw an exception if Meta is missing.
-    """
-    with pytest.raises(AttributeError):
-
-        class Address(Model):
-            street = f.TextField("Street")
-
-
-def test_model_missing_meta_attribute():
-    """
-    Test that we throw an exception if Meta is missing a required attribute.
-    """
-    with pytest.raises(ValueError):
-
-        class Address(Model):
-            street = f.TextField("Street")
-
-            class Meta:
-                base_id = "required"
-                table_name = "required"
-                # api_key = "required"
-
-
-def test_model_empty_meta():
-    """
-    Test that we throw an exception when a required Meta attribute is None.
-    """
-    with pytest.raises(ValueError):
-
-        class Address(Model):
-            Meta = fake_meta(api_key=None)
-            street = f.TextField("Street")
-
-
-def test_model_overlapping():
-    # Should raise error because conflicts with .exists()
-    with pytest.raises(ValueError):
-
-        class Address(Model):
-            Meta = fake_meta()
-            exists = f.TextField("Exists")  # clases with Model.exists()
-
-
 class Address(Model):
     Meta = fake_meta(table_name="Address")
     street = f.TextField("Street")
@@ -68,12 +24,12 @@ class Contact(Model):
     last_name = f.TextField("Last Name")
     email = f.EmailField("Email")
     is_registered = f.CheckboxField("Registered")
-    address = f.LinkField("Link", Address, lazy=True)
+    address = f.LinkField("Link", Address, lazy=False)
     birthday = f.DateField("Birthday")
     created_at = f.CreatedTimeField("Created At")
 
 
-def test_model():
+def test_model_basics():
     contact = Contact(
         first_name="Gui",
         last_name="Talarico",
@@ -111,6 +67,24 @@ def test_model():
     assert record["fields"]["First Name"] == contact.first_name
 
 
+def test_unsupplied_fields():
+    """
+    Test that we can create a record without fields.
+    """
+    a = Address()
+    assert a.number is None
+    assert a.street is None
+
+
+def test_null_fields():
+    """
+    Test that we can create a record with null fields.
+    """
+    a = Address(number=None, street=None)
+    assert a.number is None
+    assert a.street is None
+
+
 def test_first():
     with mock.patch.object(Table, "first") as m_first:
         m_first.return_value = {
@@ -200,6 +174,55 @@ def test_linked_record():
     assert contact.address[0].street == "A"
 
 
+@pytest.mark.parametrize("access_linked_records", (True, False))
+def test_linked_record_can_be_saved(requests_mock, access_linked_records):
+    """
+    Test that we can call Model.save() on a model with a non-lazy linked field,
+    whether we've already accessed the field contents or not.
+
+    Accessing the linked field converts its internal representation from
+    record IDs into instances of the model. This could interfere with save(),
+    so this test ensures we don't regress the capability.
+    """
+    address_json = fake_record(Number="123", Street="Fake St")
+    address_id = address_json["id"]
+    address_url_re = re.escape(Address.get_table().url + "?filterByFormula=")
+    contact_json = fake_record(Email="alice@example.com", Link=[address_id])
+    contact_id = contact_json["id"]
+    contact_url = Contact.get_table().record_url(contact_id)
+    contact_url_re = re.escape(Contact.get_table().url + "?filterByFormula=")
+    requests_mock.get(re.compile(address_url_re), json={"records": [address_json]})
+    requests_mock.get(re.compile(contact_url_re), json={"records": [contact_json]})
+    requests_mock.get(contact_url, json=contact_json)
+    mock_save = requests_mock.patch(contact_url, json=contact_json)
+
+    contact = Contact.from_id(contact_id)
+
+    if access_linked_records:
+        assert contact.address[0].id == address_id
+
+    contact.save()
+    assert mock_save.last_request.json() == {
+        "fields": {
+            "Email": "alice@example.com",
+            "Link": [address_id],
+        },
+        "typecast": True,
+    }
+
+
+def test_save__raise_on_unsaved_link(requests_mock):
+    """
+    Test that Model.save() raises an exception if called before saving all linked records.
+    """
+    contact = Contact(address=[Address()])
+
+    with pytest.raises(ValueError) as ctx:
+        contact.save()
+
+    assert "Contact.address contains an unsaved record" in ctx.exconly()
+
+
 @pytest.mark.parametrize(
     "test_case",
     [
@@ -300,6 +323,24 @@ def test_batch_save__invalid_class(mock_update, mock_create):
     assert mock_create.call_count == 0
 
 
+@mock.patch("pyairtable.Table.batch_create")
+@mock.patch("pyairtable.Table.batch_update")
+def test_batch_save__raise_on_unsaved_link(mock_update, mock_create):
+    """
+    Test that Model.batch_save() raises ValueError if called before
+    all linked records have been saved.
+    """
+    contacts = [Contact() for _ in range(20)]
+    contacts[14].address = [Address()]
+
+    with pytest.raises(ValueError) as ctx:
+        Contact.batch_save(contacts)
+
+    assert "Contact.address contains an unsaved record" in ctx.exconly()
+    assert mock_update.call_count == 0
+    assert mock_create.call_count == 0
+
+
 @mock.patch("pyairtable.Table.batch_delete")
 def test_batch_delete(mock_delete):
     """
diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py
index f73133b..5c247e9 100644
--- a/tests/test_orm_fields.py
+++ b/tests/test_orm_fields.py
@@ -1,11 +1,18 @@
 import datetime
 import operator
+import re
 
 import pytest
 
 from pyairtable.orm import fields as f
 from pyairtable.orm.model import Model
-from pyairtable.testing import fake_attachment, fake_meta, fake_record, fake_user
+from pyairtable.testing import (
+    fake_attachment,
+    fake_id,
+    fake_meta,
+    fake_record,
+    fake_user,
+)
 
 DATE_S = "2023-01-01"
 DATE_V = datetime.date(2023, 1, 1)
@@ -46,12 +53,12 @@ def test_field():
             "LastModifiedByField('User', readonly=True, validate_type=True)",
         ),
         (
-            f.ListField("Items", dict, validate_type=False),
-            "ListField('Items', model=<class 'dict'>, readonly=False, validate_type=False)",
+            f.LookupField[dict]("Items", validate_type=False),
+            "LookupField('Items', readonly=True, validate_type=False)",
         ),
         (
             f.LinkField("Records", type("TestModel", (Model,), {"Meta": fake_meta()})),
-            "LinkField('Records', model=<class 'abc.TestModel'>, lazy=True)",
+            "LinkField('Records', model=<class 'test_orm_fields.TestModel'>, validate_type=True, readonly=False, lazy=False)",
         ),
     ],
 )
@@ -64,8 +71,8 @@ def test_repr(instance, expected):
     argvalues=[
         (f.Field, None),
         (f.CheckboxField, False),
-        (f.ListField, []),
         (f.LookupField, []),
+        (f.MultipleAttachmentsField, []),
         (f.MultipleCollaboratorsField, []),
         (f.MultipleSelectField, []),
     ],
@@ -76,7 +83,8 @@ def test_orm_missing_values(field_type, default_value):
     when there is no field value provided from Airtable.
     """
 
-    class T:
+    class T(Model):
+        Meta = fake_meta()
         the_field = field_type("Field Name")
 
     t = T()
@@ -97,6 +105,7 @@ TYPE_VALIDATION_TEST_VALUES = {
 @pytest.mark.parametrize(
     "test_case",
     [
+        (f.Field, tuple(TYPE_VALIDATION_TEST_VALUES)),
         (f.TextField, str),
         (f.IntegerField, int),
         (f.RichTextField, str),
@@ -108,9 +117,7 @@ TYPE_VALIDATION_TEST_VALUES = {
         (f.PhoneNumberField, str),
         (f.DurationField, datetime.timedelta),
         (f.RatingField, int),
-        (f.ListField, list),
         (f.UrlField, str),
-        (f.LookupField, list),
         (f.MultipleSelectField, list),
         (f.PercentField, (int, float)),
         (f.DateField, (datetime.date, datetime.datetime)),
@@ -118,6 +125,7 @@ TYPE_VALIDATION_TEST_VALUES = {
         (f.CollaboratorField, dict),
         (f.SelectField, str),
         (f.EmailField, str),
+        (f.MultipleAttachmentsField, list),
         (f.MultipleCollaboratorsField, list),
         (f.CurrencyField, (int, float)),
     ],
@@ -152,6 +160,49 @@ def test_type_validation(test_case):
                 )
 
 
+def test_type_validation_LinkField():
+    """
+    Test that a link field will reject models of the wrong class.
+    """
+
+    class A(Model):
+        Meta = fake_meta()
+
+    class B(Model):
+        Meta = fake_meta()
+
+    class Container(Model):
+        Meta = fake_meta()
+        linked = f.LinkField("Linked", model=A)
+
+    a1 = A.from_record(fake_record())
+    a2 = A.from_record(fake_record())
+    a3 = A.from_record(fake_record())
+    b1 = B.from_record(fake_record())
+    b2 = B.from_record(fake_record())
+    b3 = B.from_record(fake_record())
+
+    record = Container()
+    assert record.linked == []
+
+    record.linked.append(a1)
+    record.linked.append(a2)
+    assert record.to_record()["fields"]["Linked"] == [a1.id, a2.id]
+
+    record.linked = [a1, a2, a3]
+    assert record.to_record()["fields"]["Linked"] == [a1.id, a2.id, a3.id]
+
+    # We can validate the type of each object during assignment
+    with pytest.raises(TypeError):
+        record.linked = [b1, b2, b3]
+
+    # We can't (easily) stop an implementer from appending the wrong type
+    # to the end of the list, but we can catch it during to_record().
+    record.linked.append(b1)
+    with pytest.raises(TypeError):
+        record.to_record()
+
+
 @pytest.mark.parametrize(
     argnames="test_case",
     argvalues=[
@@ -160,6 +211,7 @@ def test_type_validation(test_case):
         (f.CountField, 1),
         (f.ExternalSyncSourceField, "Source"),
         (f.ButtonField, {"label": "Click me!"}),
+        (f.LookupField, ["any", "values"]),
         # If a 3-tuple, we should be able to convert API -> ORM values.
         (f.CreatedByField, fake_user()),
         (f.CreatedTimeField, DATETIME_S, DATETIME_V),
@@ -206,8 +258,6 @@ def test_readonly_fields(test_case):
         (f.CurrencyField, 1.05),
         (f.CheckboxField, True),
         (f.CollaboratorField, {"id": "usrFakeUserId", "email": "x@y.com"}),
-        (f.ListField, ["any", "values"]),
-        (f.LookupField, ["any", "values"]),
         (f.MultipleAttachmentsField, [fake_attachment(), fake_attachment()]),
         (f.MultipleSelectField, ["any", "values"]),
         (f.MultipleCollaboratorsField, [fake_user(), fake_user()]),
@@ -257,6 +307,10 @@ def test_completeness():
     Ensure that we test conversion of all readonly and writable fields.
     """
     assert_all_fields_tested_by(test_writable_fields, test_readonly_fields)
+    assert_all_fields_tested_by(
+        test_type_validation,
+        exclude=f.READONLY_FIELDS | {f.LinkField},
+    )
 
 
 def assert_all_fields_tested_by(*test_fns, exclude=(f.Field, f.LinkField)):
@@ -273,11 +327,12 @@ def assert_all_fields_tested_by(*test_fns, exclude=(f.Field, f.LinkField)):
             pass
         elif isinstance(obj, dict):
             yield from extract_fields(list(obj.values()))
+        elif isinstance(obj, type):
+            if issubclass(obj, f.Field):
+                yield obj
         elif hasattr(obj, "__iter__"):
             for item in obj:
                 yield from extract_fields(item)
-        elif isinstance(obj, type) and issubclass(obj, f.Field):
-            yield obj
 
     tested_field_classes = {
         field_class
@@ -325,7 +380,7 @@ def test_list_field_with_none():
 
     class T(Model):
         Meta = fake_meta()
-        the_field = f.ListField("Fld")
+        the_field = f._ListField("Fld", str)
 
     assert T.from_record(fake_record()).the_field == []
     assert T.from_record(fake_record(Fld=None)).the_field == []
@@ -338,7 +393,7 @@ def test_list_field_with_invalid_type():
 
     class T(Model):
         Meta = fake_meta()
-        the_field = f.ListField("Field Name")
+        the_field = f._ListField("Field Name", str)
 
     obj = T.from_record(fake_record())
     with pytest.raises(TypeError):
@@ -352,14 +407,14 @@ def test_list_field_with_string():
     """
 
     class T:
-        items = f.ListField("Items")
+        items = f._ListField("Items", str)
 
     t = T()
     with pytest.raises(TypeError):
         t.items = "hello!"
 
 
-def test_linked_field_must_link_to_model():
+def test_link_field_must_link_to_model():
     """
     Tests that a LinkField cannot link to an arbitrary type.
     """
@@ -367,22 +422,150 @@ def test_linked_field_must_link_to_model():
         f.LinkField("Field Name", model=dict)
 
 
-def test_linked_field():
-    class T(Model):
+def test_link_field():
+    """
+    Test basic interactions and type checking for LinkField.
+    """
+
+    class Book(Model):
         Meta = fake_meta()
 
-    class X(Model):
+    class Author(Model):
         Meta = fake_meta()
-        t = f.LinkField("Field Name", model=T)
+        books = f.LinkField("Books", model=Book)
 
-    x = X(t=[])
-    x.t = [T(), T(), T()]
+    collection = [Book(), Book(), Book()]
+    author = Author()
+    author.books = collection
+    assert author.books == collection
 
     with pytest.raises(TypeError):
-        x.t = [1, 2, 3]
+        author.books = Book()
 
     with pytest.raises(TypeError):
-        x.t = -1
+        author.books = [1, 2, 3]
+
+    with pytest.raises(TypeError):
+        author.books = -1
+
+
+class Dummy(Model):
+    Meta = fake_meta()
+
+
+def test_link_field__linked_model():
+    """
+    Test the various ways of specifying a linked model for the LinkField.
+    """
+
+    class HasLinks(Model):
+        Meta = fake_meta()
+        # If the other model class is available, you can link to it directly
+        explicit_link = f.LinkField("explicit", Dummy)
+        # You can pass a fully qualified path.to.module.Class
+        indirect_link = f.LinkField("indirect", "test_orm_fields.Dummy")
+        # If there's no module, just a class, we assume it's in the same module
+        neighbor_link = f.LinkField("neighbor", "Dummy")
+        # Sentinel value LinkSelf means "link to the same model"
+        circular_link = f.LinkField("circular", f.LinkSelf)
+
+    assert HasLinks.explicit_link.linked_model is Dummy
+    assert HasLinks.indirect_link.linked_model is Dummy
+    assert HasLinks.neighbor_link.linked_model is Dummy
+    assert HasLinks.circular_link.linked_model is HasLinks
+
+    # While it is technically possible to add fields to a Model class after creation,
+    # those fields won't get __set_name__ called, which means they won't have a
+    # reference to their own Model class. This means...
+
+    # ...that LinkField(model=f.LinkSelf) won't work:
+    HasLinks.invalid_circular_link = f.LinkField("Invalid", f.LinkSelf)
+    with pytest.raises(RuntimeError):
+        HasLinks.invalid_circular_link.linked_model
+
+    # ...and LinkField(model="Class") without the module path won't work:
+    HasLinks.invalid_neighbor_link = f.LinkField("Invalid", "Dummy")
+    with pytest.raises(RuntimeError):
+        HasLinks.invalid_neighbor_link.linked_model
+
+
+class Person(Model):
+    Meta = fake_meta()
+    friends = f.LinkField("Friends", f.LinkSelf, lazy=False)
+
+
+def test_link_field__cycle(requests_mock):
+    """
+    Test that cyclical relationships like A -> B -> C -> A don't cause infinite recursion.
+    """
+
+    id_a = fake_id("rec", "A")
+    id_b = fake_id("rec", "B")
+    id_c = fake_id("rec", "C")
+    rec_a = {"id": id_a, "createdTime": DATETIME_S, "fields": {"Friends": [id_b]}}
+    rec_b = {"id": id_b, "createdTime": DATETIME_S, "fields": {"Friends": [id_c]}}
+    rec_c = {"id": id_c, "createdTime": DATETIME_S, "fields": {"Friends": [id_a]}}
+
+    requests_mock.get(Person.get_table().record_url(id_a), json=rec_a)
+    a = Person.from_id(id_a)
+
+    for record in (rec_a, rec_b, rec_c):
+        url_re = re.compile(
+            re.escape(Person.get_table().url + "?filterByFormula=")
+            + ".*"
+            + record["id"]
+        )
+        requests_mock.get(url_re, json={"records": [record]})
+
+    assert a.friends[0].id == id_b
+    assert a.friends[0].friends[0].id == id_c
+    assert a.friends[0].friends[0].friends[0].id == id_a
+
+
+def test_link_field__load_many(requests_mock):
+    """
+    Tests that a LinkField which returns several IDs will minimize the number
+    of API calls it makes in order to fetch data.
+    """
+
+    person_id = fake_id("rec", "A")
+    person_url = Person.get_table().record_url(person_id)
+    friend_ids = [fake_id("rec", c) for c in "123456789ABCDEF"]
+
+    person_json = {
+        "id": person_id,
+        "createdTime": DATETIME_S,
+        "fields": {"Friends": friend_ids},
+    }
+    friends_json = [
+        {"id": record_id, "createdTime": DATETIME_S, "fields": {"Name": record_id[-1]}}
+        for record_id in friend_ids
+    ]
+
+    # This retrieves a record; we test this behavior elsewhere, no need to check values
+    requests_mock.get(person_url, json=person_json)
+    person = Person.from_id(person_id)
+
+    # Here we test that calling `person.friends` retrieves records in bulk,
+    # instead of calling `.fetch()` on every record individually.
+    # The mocked URL specifically includes every record ID in our test set,
+    # to ensure the library isn't somehow dropping records from its query.
+    url_regex = ".*".join(
+        [re.escape(Person.get_table().url + "?filterByFormula="), *friend_ids]
+    )
+    mock_list = requests_mock.get(
+        re.compile(url_regex),
+        [
+            {"json": {"records": friends_json[:10], "offset": "offset1"}},
+            {"json": {"records": friends_json[10:]}},
+        ],
+    )
+    assert person.friends
+    assert mock_list.call_count == 2
+    assert len(person.friends) == len(friend_ids)
+    assert [friend.id for friend in person.friends] == friend_ids
+    # Make sure we didn't keep calling the API on every `person.friends`
+    assert mock_list.call_count == 2
 
 
 def test_lookup_field():
@@ -397,7 +580,7 @@ def test_lookup_field():
     assert rv_list[0] == "Item 1" and rv_list[1] == "Item 2" and rv_list[2] == "Item 3"
 
     class T:
-        events = f.LookupField("Event times", model=f.DatetimeField)
+        events = f.LookupField("Event times")
 
     lookup_from_airtable = [
         "2000-01-02T03:04:05.000Z",
diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py
new file mode 100644
index 0000000..a6f4a0d
--- /dev/null
+++ b/tests/test_orm_model.py
@@ -0,0 +1,101 @@
+import mock
+import pytest
+
+from pyairtable.orm import Model
+from pyairtable.orm import fields as f
+from pyairtable.testing import fake_meta, fake_record
+
+
+@pytest.fixture(autouse=True)
+def no_requests(requests_mock):
+    """
+    Fail if any tests in this module try to make network calls.
+    """
+
+
+def test_model_missing_meta():
+    """
+    Test that we throw an exception if Meta is missing.
+    """
+    with pytest.raises(AttributeError):
+
+        class Address(Model):
+            street = f.TextField("Street")
+
+
+def test_model_missing_meta_attribute():
+    """
+    Test that we throw an exception if Meta is missing a required attribute.
+    """
+    with pytest.raises(ValueError):
+
+        class Address(Model):
+            street = f.TextField("Street")
+
+            class Meta:
+                base_id = "required"
+                table_name = "required"
+                # api_key = "required"
+
+
+def test_model_empty_meta():
+    """
+    Test that we throw an exception when a required Meta attribute is None.
+    """
+    with pytest.raises(ValueError):
+
+        class Address(Model):
+            Meta = fake_meta(api_key=None)
+            street = f.TextField("Street")
+
+
+def test_model_overlapping():
+    # Should raise error because conflicts with .exists()
+    with pytest.raises(ValueError):
+
+        class Address(Model):
+            Meta = fake_meta()
+            exists = f.TextField("Exists")  # clases with Model.exists()
+
+
+def test_repr():
+    class Contact(Model):
+        Meta = fake_meta()
+
+    record = fake_record()
+    assert repr(Contact.from_record(record)) == f"<Contact id='{record['id']}'>"
+    assert repr(Contact()) == "<unsaved Contact>"
+
+
+@mock.patch("pyairtable.Table.get")
+def test_from_id(mock_get):
+    class Contact(Model):
+        Meta = fake_meta()
+        name = f.TextField("Name")
+
+    fake_contact = fake_record(Name="Alice")
+    mock_get.return_value = fake_contact
+    contact = Contact.from_id(fake_contact["id"])
+    assert contact.id == fake_contact["id"]
+    assert contact.name == "Alice"
+
+
+@mock.patch("pyairtable.Table.all")
+def test_from_ids(mock_all):
+    class Contact(Model):
+        Meta = fake_meta()
+
+    fake_records = [fake_record() for _ in range(10)]
+    mock_all.return_value = fake_records
+
+    fake_ids = [record["id"] for record in fake_records]
+    contacts = Contact.from_ids(fake_ids)
+    mock_all.assert_called_once()
+    assert len(contacts) == len(fake_records)
+    assert {c.id for c in contacts} == {r["id"] for r in fake_records}
+
+    # Should raise KeyError because of the invalid ID
+    mock_all.reset_mock()
+    with pytest.raises(KeyError):
+        Contact.from_ids(fake_ids + ["recDefinitelyNotValid"])
+    mock_all.assert_called_once()
diff --git a/tests/test_typing.py b/tests/test_typing.py
index d773d48..8e7c892 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -7,6 +7,7 @@ from typing_extensions import assert_type
 
 import pyairtable
 import pyairtable.api.types as T
+from pyairtable import orm
 
 if TYPE_CHECKING:
     # This section does not actually get executed; it is only parsed by mypy.
@@ -51,3 +52,24 @@ if TYPE_CHECKING:
     table.update(record_id, {"Field Name": None})
     table.update(record_id, {"Field Name": {"id": "usrXXX"}})
     table.update(record_id, {"Field Name": {"email": "alice@example.com"}})
+
+    # Test type annotations for the ORM
+    class Actor(orm.Model):
+        name = orm.fields.TextField("Name")
+        logins = orm.fields.MultipleCollaboratorsField("Logins")
+
+    class Movie(orm.Model):
+        name = orm.fields.TextField("Name")
+        rating = orm.fields.RatingField("Star Rating")
+        prequels = orm.fields.LinkField["Movie"]("Prequels", "path.to.Movie")
+        actors = orm.fields.LinkField("Actors", Actor)
+
+    assert_type(Actor().name, Optional[str])
+    assert_type(Actor().logins, List[T.CollaboratorDict])
+
+    movie = Movie()
+    assert_type(movie.name, Optional[str])
+    assert_type(movie.rating, Optional[int])
+    assert_type(movie.actors, List[Actor])
+    assert_type(movie.prequels, List[Movie])
+    assert_type(movie.actors[0].name, Optional[str])