diff --git a/changelogs/fragments/69795-ansible-doc-suboptions.yml b/changelogs/fragments/69795-ansible-doc-suboptions.yml
new file mode 100644
index 00000000000..8d52aba3fc2
--- /dev/null
+++ b/changelogs/fragments/69795-ansible-doc-suboptions.yml
@@ -0,0 +1,2 @@
+minor_changes:
+- "ansible-doc - improve suboptions formatting (https://github.com/ansible/ansible/pull/69795)."
diff --git a/lib/ansible/cli/doc.py b/lib/ansible/cli/doc.py
index dec582fb98d..fbf0223baca 100644
--- a/lib/ansible/cli/doc.py
+++ b/lib/ansible/cli/doc.py
@@ -503,7 +503,7 @@ class DocCLI(CLI):
                                                    Dumper=AnsibleDumper).split('\n')]))
 
     @staticmethod
-    def add_fields(text, fields, limit, opt_indent):
+    def add_fields(text, fields, limit, opt_indent, base_indent=''):
 
         for o in sorted(fields):
             opt = fields[o]
@@ -516,7 +516,7 @@ class DocCLI(CLI):
             else:
                 opt_leadin = "-"
 
-            text.append("%s %s" % (opt_leadin, o))
+            text.append("%s%s %s" % (base_indent, opt_leadin, o))
 
             if isinstance(opt['description'], list):
                 for entry_idx, entry in enumerate(opt['description'], 1):
@@ -546,13 +546,10 @@ class DocCLI(CLI):
             text.append(textwrap.fill(DocCLI.tty_ify(aliases + choices + default), limit,
                                       initial_indent=opt_indent, subsequent_indent=opt_indent))
 
-            if 'options' in opt:
-                text.append("%soptions:\n" % opt_indent)
-                DocCLI.add_fields(text, opt.pop('options'), limit, opt_indent + opt_indent)
-
-            if 'spec' in opt:
-                text.append("%sspec:\n" % opt_indent)
-                DocCLI.add_fields(text, opt.pop('spec'), limit, opt_indent + opt_indent)
+            suboptions = []
+            for subkey in ('options', 'suboptions', 'contains', 'spec'):
+                if subkey in opt:
+                    suboptions.append((subkey, opt.pop(subkey)))
 
             conf = {}
             for config in ('env', 'ini', 'yaml', 'vars', 'keywords'):
@@ -578,7 +575,13 @@ class DocCLI(CLI):
                     text.append(DocCLI.tty_ify('%s%s: %s' % (opt_indent, k, ', '.join(opt[k]))))
                 else:
                     text.append(DocCLI._dump_yaml({k: opt[k]}, opt_indent))
-            text.append('')
+
+            for subkey, subdata in suboptions:
+                text.append('')
+                text.append("%s%s:\n" % (opt_indent, subkey.upper()))
+                DocCLI.add_fields(text, subdata, limit, opt_indent + '    ', opt_indent)
+            if not suboptions:
+                text.append('')
 
     @staticmethod
     def get_man_text(doc):
diff --git a/test/integration/targets/ansible-doc/library/test_docs_suboptions.py b/test/integration/targets/ansible-doc/library/test_docs_suboptions.py
new file mode 100644
index 00000000000..c922d1d6cc3
--- /dev/null
+++ b/test/integration/targets/ansible-doc/library/test_docs_suboptions.py
@@ -0,0 +1,70 @@
+#!/usr/bin/python
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+
+DOCUMENTATION = '''
+---
+module: test_docs_suboptions
+short_description: Test module
+description:
+    - Test module
+author:
+    - Ansible Core Team
+options:
+    with_suboptions:
+        description:
+            - An option with suboptions.
+            - Use with care.
+        type: dict
+        suboptions:
+            z_last:
+                description: The last suboption.
+                type: str
+            m_middle:
+                description:
+                    - The suboption in the middle.
+                    - Has its own suboptions.
+                suboptions:
+                    a_suboption:
+                        description: A sub-suboption.
+                        type: str
+            a_first:
+                description: The first suboption.
+                type: str
+'''
+
+EXAMPLES = '''
+'''
+
+RETURN = '''
+'''
+
+
+from ansible.module_utils.basic import AnsibleModule
+
+
+def main():
+    module = AnsibleModule(
+        argument_spec=dict(
+            test_docs_suboptions=dict(
+                type='dict',
+                options=dict(
+                    a_first=dict(type='str'),
+                    m_middle=dict(
+                        type='dict',
+                        options=dict(
+                            a_suboption=dict(type='str')
+                        ),
+                    ),
+                    z_last=dict(type='str'),
+                ),
+            ),
+        ),
+    )
+
+    module.exit_json()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/test/integration/targets/ansible-doc/runme.sh b/test/integration/targets/ansible-doc/runme.sh
index 2dc32b8674d..84a69ee103b 100755
--- a/test/integration/targets/ansible-doc/runme.sh
+++ b/test/integration/targets/ansible-doc/runme.sh
@@ -12,6 +12,11 @@ current_out="$(ansible-doc --playbook-dir ./ testns.testcol.fakemodule)"
 expected_out="$(cat fakemodule.output)"
 test "$current_out" == "$expected_out"
 
+# test module docs from collection
+current_out="$(ansible-doc --playbook-dir ./ test_docs_suboptions)"
+expected_out="$(cat test_docs_suboptions.output)"
+test "$current_out" == "$expected_out"
+
 # test listing diff plugin types from collection
 for ptype in cache inventory lookup vars
 do
diff --git a/test/integration/targets/ansible-doc/test_docs_suboptions.output b/test/integration/targets/ansible-doc/test_docs_suboptions.output
new file mode 100644
index 00000000000..fb2a993f237
--- /dev/null
+++ b/test/integration/targets/ansible-doc/test_docs_suboptions.output
@@ -0,0 +1,46 @@
+> TEST_DOCS_SUBOPTIONS    (/home/felix/projects/code/github-cloned/ansible/test/integration/targets/ansible-doc/library/test_docs_suboptions.py)
+
+        Test module
+
+OPTIONS (= is mandatory):
+
+- with_suboptions
+        An option with suboptions.
+        Use with care.
+        [Default: (null)]
+        type: dict
+
+        SUBOPTIONS:
+
+        - a_first
+            The first suboption.
+            [Default: (null)]
+            type: str
+
+        - m_middle
+            The suboption in the middle.
+            Has its own suboptions.
+            [Default: (null)]
+
+            SUBOPTIONS:
+
+            - a_suboption
+                A sub-suboption.
+                [Default: (null)]
+                type: str
+
+        - z_last
+            The last suboption.
+            [Default: (null)]
+            type: str
+
+
+AUTHOR: Ansible Core Team
+
+EXAMPLES:
+
+
+
+
+RETURN VALUES:
+