Repository: scikit-hep/awkward

Issue:
`Form` is not guaranteed to preserve record field ordering under serialisation
### Version of Awkward Array

main

### Description and code to reproduce
JSON does not define the object key ordering. We should modify the form for `RecordArray` to make it preserve the order, by defining a new `"recordlookup"` or `"fields"` field that stores the ordered field names. When this field is found, the `"contents"` field should be a JSON array, rather than an object.


Discussion:

Resolving patch:
diff --git a/src/awkward/forms/form.py b/src/awkward/forms/form.py
--- a/src/awkward/forms/form.py
+++ b/src/awkward/forms/form.py
@@ -2,9 +2,11 @@
 from __future__ import annotations
 
 import json
-from typing import Any, Mapping
+from collections.abc import Mapping
+from typing import Any
 
 import awkward as ak
+from awkward import _errors
 
 np = ak.nplikes.NumpyMetadata.instance()
 
@@ -65,12 +67,22 @@ def from_dict(input: dict) -> Form:
         )
 
     elif input["class"] == "RecordArray":
-        if isinstance(input["contents"], dict):
+        # New serialisation
+        if "fields" in input:
+            if isinstance(input["contents"], Mapping):
+                raise _errors.wrap_error(
+                    TypeError("new-style RecordForm contents must not be mappings")
+                )
+            contents = [from_dict(content) for content in input["contents"]]
+            fields = input["fields"]
+        # Old style record
+        elif isinstance(input["contents"], dict):
             contents = []
             fields = []
             for key, content in input["contents"].items():
                 contents.append(from_dict(content))
                 fields.append(key)
+        # Old style tuple
         else:
             contents = [from_dict(content) for content in input["contents"]]
             fields = None
@@ -154,14 +166,14 @@ def from_dict(input: dict) -> Form:
         )
 
     elif input["class"] == "VirtualArray":
-        raise ak._errors.wrap_error(
+        raise _errors.wrap_error(
             ValueError("Awkward 1.x VirtualArrays are not supported")
         )
 
     else:
-        raise ak._errors.wrap_error(
+        raise _errors.wrap_error(
             ValueError(
-                "Input class: {} was not recognised".format(repr(input["class"]))
+                "input class: {} was not recognised".format(repr(input["class"]))
             )
         )
 
@@ -265,7 +277,7 @@ class Form:
 
     def _init(self, has_identifier, parameters, form_key):
         if not isinstance(has_identifier, bool):
-            raise ak._errors.wrap_error(
+            raise _errors.wrap_error(
                 TypeError(
                     "{} 'has_identifier' must be of type bool, not {}".format(
                         type(self).__name__, repr(has_identifier)
@@ -273,7 +285,7 @@ def _init(self, has_identifier, parameters, form_key):
                 )
             )
         if parameters is not None and not isinstance(parameters, dict):
-            raise ak._errors.wrap_error(
+            raise _errors.wrap_error(
                 TypeError(
                     "{} 'parameters' must be of type dict or None, not {}".format(
                         type(self).__name__, repr(parameters)
@@ -281,7 +293,7 @@ def _init(self, has_identifier, parameters, form_key):
                 )
             )
         if form_key is not None and not ak._util.isstr(form_key):
-            raise ak._errors.wrap_error(
+            raise _errors.wrap_error(
                 TypeError(
                     "{} 'form_key' must be of type string or None, not {}".format(
                         type(self).__name__, repr(form_key)
@@ -306,7 +318,7 @@ def parameters(self):
     @property
     def is_identity_like(self):
         """Return True if the content or its non-list descendents are an identity"""
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     def parameter(self, key):
         if self._parameters is None:
@@ -315,31 +327,31 @@ def parameter(self, key):
             return self._parameters.get(key)
 
     def purelist_parameter(self, key):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     @property
     def purelist_isregular(self):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     @property
     def purelist_depth(self):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     @property
     def minmax_depth(self):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     @property
     def branch_depth(self):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     @property
     def fields(self):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     @property
     def is_tuple(self):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     @property
     def form_key(self):
@@ -396,7 +408,7 @@ def select_columns(self, specifier, expand_braces=True):
 
         for item in specifier:
             if not ak._util.isstr(item):
-                raise ak._errors.wrap_error(
+                raise _errors.wrap_error(
                     TypeError("a column-selection specifier must be a list of strings")
                 )
 
@@ -417,16 +429,16 @@ def column_types(self):
         return self._column_types()
 
     def _columns(self, path, output, list_indicator):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     def _select_columns(self, index, specifier, matches, output):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     def _column_types(self):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
 
     def _to_dict_part(self, verbose, toplevel):
-        raise ak._errors._errors(NotImplementedError)
+        raise _errors._errors(NotImplementedError)
 
     def _type(self, typestrs):
-        raise ak._errors.wrap_error(NotImplementedError)
+        raise _errors.wrap_error(NotImplementedError)
diff --git a/src/awkward/forms/recordform.py b/src/awkward/forms/recordform.py
--- a/src/awkward/forms/recordform.py
+++ b/src/awkward/forms/recordform.py
@@ -139,10 +139,11 @@ def _to_dict_part(self, verbose, toplevel):
             content._to_dict_part(verbose, toplevel=False) for content in self._contents
         ]
         if self._fields is not None:
-            out["contents"] = dict(zip(self._fields, contents_tolist))
+            out["fields"] = list(self._fields)
         else:
-            out["contents"] = contents_tolist
+            out["fields"] = None
 
+        out["contents"] = contents_tolist
         return self._to_dict_extra(out, verbose)
 
     def _type(self, typestrs):


Test patch:
diff --git a/tests/test_0025-record-array.py b/tests/test_0025-record-array.py
--- a/tests/test_0025-record-array.py
+++ b/tests/test_0025-record-array.py
@@ -43,8 +43,9 @@ def test_basic():
     assert json.loads(ak.forms.form.Form.to_json(recordarray.form)) == (
         {
             "class": "RecordArray",
-            "contents": {
-                "one": {
+            "fields": ["one", "two", "2", "wonky"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "int64",
                     "inner_shape": [],
@@ -52,7 +53,7 @@ def test_basic():
                     "parameters": {},
                     "form_key": None,
                 },
-                "two": {
+                {
                     "class": "ListOffsetArray",
                     "offsets": "i64",
                     "content": {
@@ -67,7 +68,7 @@ def test_basic():
                     "parameters": {},
                     "form_key": None,
                 },
-                "2": {
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -75,7 +76,7 @@ def test_basic():
                     "parameters": {},
                     "form_key": None,
                 },
-                "wonky": {
+                {
                     "class": "NumpyArray",
                     "primitive": "int64",
                     "inner_shape": [],
@@ -83,7 +84,7 @@ def test_basic():
                     "parameters": {},
                     "form_key": None,
                 },
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -135,8 +136,9 @@ def test_basic():
 
     assert json.loads(ak.forms.form.Form.to_json(recordarray.form)) == {
         "class": "RecordArray",
-        "contents": {
-            "one": {
+        "fields": ["one", "two", "2", "wonky"],
+        "contents": [
+            {
                 "class": "NumpyArray",
                 "primitive": "int64",
                 "inner_shape": [],
@@ -144,7 +146,7 @@ def test_basic():
                 "parameters": {},
                 "form_key": None,
             },
-            "two": {
+            {
                 "class": "ListOffsetArray",
                 "offsets": "i64",
                 "content": {
@@ -159,7 +161,7 @@ def test_basic():
                 "parameters": {},
                 "form_key": None,
             },
-            "2": {
+            {
                 "class": "NumpyArray",
                 "primitive": "float64",
                 "inner_shape": [],
@@ -167,7 +169,7 @@ def test_basic():
                 "parameters": {},
                 "form_key": None,
             },
-            "wonky": {
+            {
                 "class": "NumpyArray",
                 "primitive": "int64",
                 "inner_shape": [],
@@ -175,7 +177,7 @@ def test_basic():
                 "parameters": {},
                 "form_key": None,
             },
-        },
+        ],
         "has_identifier": False,
         "parameters": {},
         "form_key": None,
diff --git a/tests/test_0057-introducing-forms.py b/tests/test_0057-introducing-forms.py
--- a/tests/test_0057-introducing-forms.py
+++ b/tests/test_0057-introducing-forms.py
@@ -265,8 +265,9 @@ def test_forms():
     assert ak.forms.from_json(form.to_json()) == form
     assert json.loads(form.to_json()) == {
         "class": "RecordArray",
-        "contents": {
-            "one": {
+        "fields": ["one", "two"],
+        "contents": [
+            {
                 "class": "NumpyArray",
                 "inner_shape": [],
                 "primitive": "float64",
@@ -274,7 +275,7 @@ def test_forms():
                 "parameters": {},
                 "form_key": None,
             },
-            "two": {
+            {
                 "class": "NumpyArray",
                 "inner_shape": [],
                 "primitive": "bool",
@@ -282,17 +283,18 @@ def test_forms():
                 "parameters": {},
                 "form_key": None,
             },
-        },
+        ],
         "has_identifier": False,
         "parameters": {"hey": ["you"]},
         "form_key": "yowzers",
     }
     assert json.loads(str(form)) == {
         "class": "RecordArray",
-        "contents": {
-            "one": "float64",
-            "two": "bool",
-        },
+        "fields": ["one", "two"],
+        "contents": [
+            "float64",
+            "bool",
+        ],
         "parameters": {"hey": ["you"]},
         "form_key": "yowzers",
     }
@@ -308,6 +310,7 @@ def test_forms():
     assert ak.forms.from_json(form.to_json()) == form
     assert json.loads(form.to_json()) == {
         "class": "RecordArray",
+        "fields": None,
         "contents": [
             {
                 "class": "NumpyArray",
@@ -332,6 +335,7 @@ def test_forms():
     }
     assert json.loads(str(form)) == {
         "class": "RecordArray",
+        "fields": None,
         "contents": ["float64", "bool"],
         "parameters": {"hey": ["you"]},
         "form_key": "yowzers",
diff --git a/tests/test_0914-types-and-forms.py b/tests/test_0914-types-and-forms.py
--- a/tests/test_0914-types-and-forms.py
+++ b/tests/test_0914-types-and-forms.py
@@ -2581,6 +2581,7 @@ def test_RecordForm():
         )
         == """{
     "class": "RecordArray",
+    "fields": null,
     "contents": [
         {
             "class": "EmptyArray"
@@ -2601,12 +2602,16 @@ def test_RecordForm():
         )
         == """{
     "class": "RecordArray",
-    "contents": {
-        "x": {
+    "fields": [
+        "x",
+        "y"
+    ],
+    "contents": [
+        {
             "class": "EmptyArray"
         },
-        "y": "bool"
-    }
+        "bool"
+    ]
 }"""
     )
     assert (
@@ -2624,6 +2629,7 @@ def test_RecordForm():
         )
         == """{
     "class": "RecordArray",
+    "fields": null,
     "contents": [
         {
             "class": "EmptyArray"
@@ -2652,12 +2658,16 @@ def test_RecordForm():
         )
         == """{
     "class": "RecordArray",
-    "contents": {
-        "x": {
+    "fields": [
+        "x",
+        "y"
+    ],
+    "contents": [
+        {
             "class": "EmptyArray"
         },
-        "y": "bool"
-    },
+        "bool"
+    ],
     "has_identifier": true,
     "parameters": {
         "x": 123
@@ -2726,6 +2736,7 @@ def test_RecordForm():
         None,
     ).to_dict(verbose=False) == {
         "class": "RecordArray",
+        "fields": None,
         "contents": [
             {"class": "EmptyArray"},
             "bool",
@@ -2736,16 +2747,18 @@ def test_RecordForm():
         ["x", "y"],
     ).to_dict(verbose=False) == {
         "class": "RecordArray",
-        "contents": {
-            "x": {"class": "EmptyArray"},
-            "y": "bool",
-        },
+        "fields": ["x", "y"],
+        "contents": [
+            {"class": "EmptyArray"},
+            "bool",
+        ],
     }
     assert ak.forms.recordform.RecordForm(
         [ak.forms.emptyform.EmptyForm(), ak.forms.numpyform.NumpyForm("bool")],
         None,
     ).to_dict() == {
         "class": "RecordArray",
+        "fields": None,
         "contents": [
             {
                 "class": "EmptyArray",
@@ -2771,14 +2784,15 @@ def test_RecordForm():
         ["x", "y"],
     ).to_dict() == {
         "class": "RecordArray",
-        "contents": {
-            "x": {
+        "fields": ["x", "y"],
+        "contents": [
+            {
                 "class": "EmptyArray",
                 "has_identifier": False,
                 "parameters": {},
                 "form_key": None,
             },
-            "y": {
+            {
                 "class": "NumpyArray",
                 "primitive": "bool",
                 "inner_shape": [],
@@ -2786,7 +2800,7 @@ def test_RecordForm():
                 "parameters": {},
                 "form_key": None,
             },
-        },
+        ],
         "has_identifier": False,
         "parameters": {},
         "form_key": None,
@@ -2802,6 +2816,7 @@ def test_RecordForm():
         form_key="hello",
     ).to_dict(verbose=False) == {
         "class": "RecordArray",
+        "fields": None,
         "contents": [
             {"class": "EmptyArray"},
             "bool",
@@ -2821,10 +2836,11 @@ def test_RecordForm():
         form_key="hello",
     ).to_dict(verbose=False) == {
         "class": "RecordArray",
-        "contents": {
-            "x": {"class": "EmptyArray"},
-            "y": "bool",
-        },
+        "fields": ["x", "y"],
+        "contents": [
+            {"class": "EmptyArray"},
+            "bool",
+        ],
         "has_identifier": True,
         "parameters": {"x": 123},
         "form_key": "hello",
@@ -2832,6 +2848,7 @@ def test_RecordForm():
     assert ak.forms.from_dict(
         {
             "class": "RecordArray",
+            "fields": None,
             "contents": [
                 {"class": "EmptyArray"},
                 "bool",
@@ -2839,6 +2856,7 @@ def test_RecordForm():
         }
     ).to_dict() == {
         "class": "RecordArray",
+        "fields": None,
         "contents": [
             {
                 "class": "EmptyArray",
@@ -2862,21 +2880,23 @@ def test_RecordForm():
     assert ak.forms.from_dict(
         {
             "class": "RecordArray",
-            "contents": {
-                "x": {"class": "EmptyArray"},
-                "y": "bool",
-            },
+            "fields": ["x", "y"],
+            "contents": [
+                {"class": "EmptyArray"},
+                "bool",
+            ],
         }
     ).to_dict() == {
         "class": "RecordArray",
-        "contents": {
-            "x": {
+        "fields": ["x", "y"],
+        "contents": [
+            {
                 "class": "EmptyArray",
                 "has_identifier": False,
                 "parameters": {},
                 "form_key": None,
             },
-            "y": {
+            {
                 "class": "NumpyArray",
                 "primitive": "bool",
                 "inner_shape": [],
@@ -2884,7 +2904,7 @@ def test_RecordForm():
                 "parameters": {},
                 "form_key": None,
             },
-        },
+        ],
         "has_identifier": False,
         "parameters": {},
         "form_key": None,
@@ -2892,6 +2912,7 @@ def test_RecordForm():
     assert ak.forms.from_dict(
         {
             "class": "RecordArray",
+            "fields": None,
             "contents": [
                 {"class": "EmptyArray"},
                 "bool",
@@ -2902,6 +2923,7 @@ def test_RecordForm():
         }
     ).to_dict(verbose=False) == {
         "class": "RecordArray",
+        "fields": None,
         "contents": [
             {"class": "EmptyArray"},
             "bool",
@@ -2913,20 +2935,22 @@ def test_RecordForm():
     assert ak.forms.from_dict(
         {
             "class": "RecordArray",
-            "contents": {
-                "x": {"class": "EmptyArray"},
-                "y": "bool",
-            },
+            "fields": ["x", "y"],
+            "contents": [
+                {"class": "EmptyArray"},
+                "bool",
+            ],
             "has_identifier": True,
             "parameters": {"x": 123},
             "form_key": "hello",
         }
     ).to_dict(verbose=False) == {
         "class": "RecordArray",
-        "contents": {
-            "x": {"class": "EmptyArray"},
-            "y": "bool",
-        },
+        "fields": ["x", "y"],
+        "contents": [
+            {"class": "EmptyArray"},
+            "bool",
+        ],
         "has_identifier": True,
         "parameters": {"x": 123},
         "form_key": "hello",
diff --git a/tests/test_0958-new-forms-must-accept-old-form-json.py b/tests/test_0958-new-forms-must-accept-old-form-json.py
--- a/tests/test_0958-new-forms-must-accept-old-form-json.py
+++ b/tests/test_0958-new-forms-must-accept-old-form-json.py
@@ -141,8 +141,9 @@ def test_RecordArray_NumpyArray():
     v2 = ak.forms.from_dict(v1).to_dict()
     assert v2 == {
         "class": "RecordArray",
-        "contents": {
-            "x": {
+        "fields": ["x", "y"],
+        "contents": [
+            {
                 "class": "NumpyArray",
                 "primitive": "int64",
                 "inner_shape": [],
@@ -150,7 +151,7 @@ def test_RecordArray_NumpyArray():
                 "parameters": {},
                 "form_key": None,
             },
-            "y": {
+            {
                 "class": "NumpyArray",
                 "primitive": "float64",
                 "inner_shape": [],
@@ -158,7 +159,7 @@ def test_RecordArray_NumpyArray():
                 "parameters": {},
                 "form_key": None,
             },
-        },
+        ],
         "has_identifier": False,
         "parameters": {},
         "form_key": None,
@@ -170,6 +171,7 @@ def test_RecordArray_NumpyArray():
     v2 = ak.forms.from_dict(v1).to_dict()
     assert v2 == {
         "class": "RecordArray",
+        "fields": None,
         "contents": [
             {
                 "class": "NumpyArray",
@@ -199,7 +201,8 @@ def test_RecordArray_NumpyArray():
     v2 = ak.forms.from_dict(v1).to_dict()
     assert v2 == {
         "class": "RecordArray",
-        "contents": {},
+        "fields": [],
+        "contents": [],
         "has_identifier": False,
         "parameters": {},
         "form_key": None,
@@ -211,6 +214,7 @@ def test_RecordArray_NumpyArray():
     v2 = ak.forms.from_dict(v1).to_dict()
     assert v2 == {
         "class": "RecordArray",
+        "fields": None,
         "contents": [],
         "has_identifier": False,
         "parameters": {},
@@ -460,8 +464,9 @@ def test_RegularArray_RecordArray_NumpyArray():
         "size": 3,
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -469,7 +474,7 @@ def test_RegularArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -488,14 +493,15 @@ def test_RegularArray_RecordArray_NumpyArray():
         "size": 0,
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "EmptyArray",
                     "has_identifier": False,
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -517,8 +523,9 @@ def test_ListArray_RecordArray_NumpyArray():
         "stops": "i64",
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -526,7 +533,7 @@ def test_ListArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -547,8 +554,9 @@ def test_ListOffsetArray_RecordArray_NumpyArray():
         "offsets": "i64",
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -556,7 +564,7 @@ def test_ListOffsetArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -577,8 +585,9 @@ def test_IndexedArray_RecordArray_NumpyArray():
         "index": "i64",
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -586,7 +595,7 @@ def test_IndexedArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -607,8 +616,9 @@ def test_IndexedOptionArray_RecordArray_NumpyArray():
         "index": "i64",
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -616,7 +626,7 @@ def test_IndexedOptionArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -638,8 +648,9 @@ def test_ByteMaskedArray_RecordArray_NumpyArray():
         "valid_when": True,
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -647,7 +658,7 @@ def test_ByteMaskedArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -667,8 +678,9 @@ def test_ByteMaskedArray_RecordArray_NumpyArray():
         "valid_when": False,
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -676,7 +688,7 @@ def test_ByteMaskedArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -699,8 +711,9 @@ def test_BitMaskedArray_RecordArray_NumpyArray():
         "lsb_order": False,
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -708,7 +721,7 @@ def test_BitMaskedArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -729,8 +742,9 @@ def test_BitMaskedArray_RecordArray_NumpyArray():
         "lsb_order": False,
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -738,7 +752,7 @@ def test_BitMaskedArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -759,8 +773,9 @@ def test_BitMaskedArray_RecordArray_NumpyArray():
         "lsb_order": True,
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -768,7 +783,7 @@ def test_BitMaskedArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -789,8 +804,9 @@ def test_BitMaskedArray_RecordArray_NumpyArray():
         "lsb_order": True,
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -798,7 +814,7 @@ def test_BitMaskedArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -818,8 +834,9 @@ def test_UnmaskedArray_RecordArray_NumpyArray():
         "class": "UnmaskedArray",
         "content": {
             "class": "RecordArray",
-            "contents": {
-                "nest": {
+            "fields": ["nest"],
+            "contents": [
+                {
                     "class": "NumpyArray",
                     "primitive": "float64",
                     "inner_shape": [],
@@ -827,7 +844,7 @@ def test_UnmaskedArray_RecordArray_NumpyArray():
                     "parameters": {},
                     "form_key": None,
                 }
-            },
+            ],
             "has_identifier": False,
             "parameters": {},
             "form_key": None,
@@ -850,8 +867,9 @@ def test_UnionArray_RecordArray_NumpyArray():
         "contents": [
             {
                 "class": "RecordArray",
-                "contents": {
-                    "nest": {
+                "fields": ["nest"],
+                "contents": [
+                    {
                         "class": "NumpyArray",
                         "primitive": "int64",
                         "inner_shape": [],
@@ -859,15 +877,16 @@ def test_UnionArray_RecordArray_NumpyArray():
                         "parameters": {},
                         "form_key": None,
                     }
-                },
+                ],
                 "has_identifier": False,
                 "parameters": {},
                 "form_key": None,
             },
             {
                 "class": "RecordArray",
-                "contents": {
-                    "nest": {
+                "fields": ["nest"],
+                "contents": [
+                    {
                         "class": "NumpyArray",
                         "primitive": "float64",
                         "inner_shape": [],
@@ -875,7 +894,7 @@ def test_UnionArray_RecordArray_NumpyArray():
                         "parameters": {},
                         "form_key": None,
                     }
-                },
+                ],
                 "has_identifier": False,
                 "parameters": {},
                 "form_key": None,
diff --git a/tests/test_1766-record-form-fields.py b/tests/test_1766-record-form-fields.py
new file mode 100644
--- /dev/null
+++ b/tests/test_1766-record-form-fields.py
@@ -0,0 +1,204 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
+
+import numpy as np  # noqa: F401
+import pytest  # noqa: F401
+
+import awkward as ak  # noqa: F401
+
+
+def test_new_style_record():
+    form = {
+        "class": "RecordArray",
+        "fields": ["z", "y"],
+        "contents": [
+            {
+                "class": "NumpyArray",
+                "primitive": "int64",
+                "inner_shape": [],
+                "has_identifier": False,
+                "parameters": {},
+                "form_key": "node1",
+            },
+            {
+                "class": "NumpyArray",
+                "primitive": "int64",
+                "inner_shape": [],
+                "has_identifier": False,
+                "parameters": {},
+                "form_key": "node2",
+            },
+        ],
+        "has_identifier": False,
+        "parameters": {},
+        "form_key": "node0",
+    }
+
+    array = ak.from_buffers(
+        form,
+        1,
+        {
+            "node1-data": np.array([1], dtype=np.int64),
+            "node2-data": np.array([2], dtype=np.int64),
+        },
+    )
+    assert not array.is_tuple
+    assert array.fields == ["z", "y"]
+    assert array.to_list() == [{"z": 1, "y": 2}]
+
+
+def test_new_style_tuple():
+    form = {
+        "class": "RecordArray",
+        "fields": None,
+        "contents": [
+            {
+                "class": "NumpyArray",
+                "primitive": "int64",
+                "inner_shape": [],
+                "has_identifier": False,
+                "parameters": {},
+                "form_key": "node1",
+            },
+            {
+                "class": "NumpyArray",
+                "primitive": "int64",
+                "inner_shape": [],
+                "has_identifier": False,
+                "parameters": {},
+                "form_key": "node2",
+            },
+        ],
+        "has_identifier": False,
+        "parameters": {},
+        "form_key": "node0",
+    }
+
+    array = ak.from_buffers(
+        form,
+        1,
+        {
+            "node1-data": np.array([1], dtype=np.int64),
+            "node2-data": np.array([2], dtype=np.int64),
+        },
+    )
+    assert array.is_tuple
+    assert array.fields == ["0", "1"]
+    assert array.to_list() == [(1, 2)]
+
+
+def test_old_style_record():
+    form = {
+        "class": "RecordArray",
+        "contents": {
+            "z": {
+                "class": "NumpyArray",
+                "primitive": "int64",
+                "inner_shape": [],
+                "has_identifier": False,
+                "parameters": {},
+                "form_key": "node1",
+            },
+            "y": {
+                "class": "NumpyArray",
+                "primitive": "int64",
+                "inner_shape": [],
+                "has_identifier": False,
+                "parameters": {},
+                "form_key": "node2",
+            },
+        },
+        "has_identifier": False,
+        "parameters": {},
+        "form_key": "node0",
+    }
+
+    array = ak.from_buffers(
+        form,
+        1,
+        {
+            "node1-data": np.array([1], dtype=np.int64),
+            "node2-data": np.array([2], dtype=np.int64),
+        },
+    )
+    assert not array.is_tuple
+    assert array.fields == ["z", "y"]
+    assert array.to_list() == [{"z": 1, "y": 2}]
+
+
+def test_old_style_tuple():
+    form = {
+        "class": "RecordArray",
+        "contents": [
+            {
+                "class": "NumpyArray",
+                "primitive": "int64",
+                "inner_shape": [],
+                "has_identifier": False,
+                "parameters": {},
+                "form_key": "node1",
+            },
+            {
+                "class": "NumpyArray",
+                "primitive": "int64",
+                "inner_shape": [],
+                "has_identifier": False,
+                "parameters": {},
+                "form_key": "node2",
+            },
+        ],
+        "has_identifier": False,
+        "parameters": {},
+        "form_key": "node0",
+    }
+
+    array = ak.from_buffers(
+        form,
+        1,
+        {
+            "node1-data": np.array([1], dtype=np.int64),
+            "node2-data": np.array([2], dtype=np.int64),
+        },
+    )
+    assert array.is_tuple
+    assert array.fields == ["0", "1"]
+    assert array.to_list() == [(1, 2)]
+
+
+def test_old_style_contents_new_style_list():
+    form = {
+        "class": "RecordArray",
+        "fields": ["x", "y"],
+        "contents": {
+            "x": {
+                "class": "NumpyArray",
+                "primitive": "int64",
+                "inner_shape": [],
+                "has_identifier": False,
+                "parameters": {},
+                "form_key": "node1",
+            },
+            "y": {
+                "class": "NumpyArray",
+                "primitive": "int64",
+                "inner_shape": [],
+                "has_identifier": False,
+                "parameters": {},
+                "form_key": "node2",
+            },
+        },
+        "has_identifier": False,
+        "parameters": {},
+        "form_key": "node0",
+    }
+
+    with pytest.raises(
+        TypeError, match=".*new-style RecordForm contents must not be mappings.*"
+    ):
+        ak.from_buffers(
+            form,
+            1,
+            {
+                "node1-data": np.array([1], dtype=np.int64),
+                "node2-data": np.array([2], dtype=np.int64),
+            },
+        )