diff --git a/doc/source/cli/command-objects/volume-group.rst b/doc/source/cli/command-objects/volume-group.rst
new file mode 100644
index 0000000000..50bc830f90
--- /dev/null
+++ b/doc/source/cli/command-objects/volume-group.rst
@@ -0,0 +1,23 @@
+============
+volume group
+============
+
+Block Storage v3
+
+.. autoprogram-cliff:: openstack.volume.v3
+   :command: volume group create
+
+.. autoprogram-cliff:: openstack.volume.v3
+   :command: volume group delete
+
+.. autoprogram-cliff:: openstack.volume.v3
+   :command: volume group list
+
+.. autoprogram-cliff:: openstack.volume.v3
+   :command: volume group failover
+
+.. autoprogram-cliff:: openstack.volume.v3
+   :command: volume group set
+
+.. autoprogram-cliff:: openstack.volume.v3
+   :command: volume group show
diff --git a/doc/source/cli/commands.rst b/doc/source/cli/commands.rst
index cc95b3d121..b91a896f41 100644
--- a/doc/source/cli/commands.rst
+++ b/doc/source/cli/commands.rst
@@ -159,6 +159,7 @@ referring to both Compute and Volume quotas.
 * ``volume backend pool``: (**Volume**) volume backend storage pools
 * ``volume backup record``: (**Volume**) volume record that can be imported or exported
 * ``volume backend``: (**Volume**) volume backend storage
+* ``volume group``: (**Volume**) group of volumes
 * ``volume host``: (**Volume**) the physical computer for volumes
 * ``volume message``: (**Volume**) volume API internal messages detailing volume failure messages
 * ``volume qos``: (**Volume**) quality-of-service (QoS) specification for volumes
diff --git a/doc/source/cli/data/cinder.csv b/doc/source/cli/data/cinder.csv
index 649cd8f59a..031aa43900 100644
--- a/doc/source/cli/data/cinder.csv
+++ b/doc/source/cli/data/cinder.csv
@@ -44,15 +44,15 @@ force-delete,volume delete --force,"Attempts force-delete of volume regardless o
 freeze-host,volume host set --disable,Freeze and disable the specified cinder-volume host.
 get-capabilities,volume backend capability show,Show capabilities of a volume backend. Admin only.
 get-pools,volume backend pool list,Show pool information for backends. Admin only.
-group-create,,Creates a group. (Supported by API versions 3.13 - 3.latest)
+group-create,volume group create,Creates a group. (Supported by API versions 3.13 - 3.latest)
 group-create-from-src,,Creates a group from a group snapshot or a source group. (Supported by API versions 3.14 - 3.latest)
-group-delete,,Removes one or more groups. (Supported by API versions 3.13 - 3.latest)
-group-disable-replication,,Disables replication for group. (Supported by API versions 3.38 - 3.latest)
-group-enable-replication,,Enables replication for group. (Supported by API versions 3.38 - 3.latest)
-group-failover-replication,,Fails over replication for group. (Supported by API versions 3.38 - 3.latest)
-group-list,,Lists all groups. (Supported by API versions 3.13 - 3.latest)
-group-list-replication-targets,,Lists replication targets for group. (Supported by API versions 3.38 - 3.latest)
-group-show,,Shows details of a group. (Supported by API versions 3.13 - 3.latest)
+group-delete,volume group delete,Removes one or more groups. (Supported by API versions 3.13 - 3.latest)
+group-disable-replication,volume group set --disable-replication,Disables replication for group. (Supported by API versions 3.38 - 3.latest)
+group-enable-replication,volume group set --enable-replication,Enables replication for group. (Supported by API versions 3.38 - 3.latest)
+group-failover-replication,volume group failover,Fails over replication for group. (Supported by API versions 3.38 - 3.latest)
+group-list,volume group list,Lists all groups. (Supported by API versions 3.13 - 3.latest)
+group-list-replication-targets,volume group list --replication-targets,Lists replication targets for group. (Supported by API versions 3.38 - 3.latest)
+group-show,volume group show,Shows details of a group. (Supported by API versions 3.13 - 3.latest)
 group-snapshot-create,,Creates a group snapshot. (Supported by API versions 3.14 - 3.latest)
 group-snapshot-delete,,Removes one or more group snapshots. (Supported by API versions 3.14 - 3.latest)
 group-snapshot-list,,Lists all group snapshots. (Supported by API versions 3.14 - 3.latest)
@@ -65,7 +65,7 @@ group-type-key,,Sets or unsets group_spec for a group type. (Supported by API ve
 group-type-list,,Lists available 'group types'. (Admin only will see private types) (Supported by API versions 3.11 - 3.latest)
 group-type-show,,Show group type details. (Supported by API versions 3.11 - 3.latest)
 group-type-update,,Updates group type name description and/or is_public. (Supported by API versions 3.11 - 3.latest)
-group-update,,Updates a group. (Supported by API versions 3.13 - 3.latest)
+group-update,volume group set,Updates a group. (Supported by API versions 3.13 - 3.latest)
 image-metadata,volume set --image-property,Sets or deletes volume image metadata.
 image-metadata-show,volume show,Shows volume image metadata.
 list,volume list,Lists all volumes.
diff --git a/openstackclient/tests/unit/volume/v3/fakes.py b/openstackclient/tests/unit/volume/v3/fakes.py
index 45cad8c14c..b0c96290f8 100644
--- a/openstackclient/tests/unit/volume/v3/fakes.py
+++ b/openstackclient/tests/unit/volume/v3/fakes.py
@@ -32,10 +32,16 @@ class FakeVolumeClient(object):
 
         self.attachments = mock.Mock()
         self.attachments.resource_class = fakes.FakeResource(None, {})
+        self.groups = mock.Mock()
+        self.groups.resource_class = fakes.FakeResource(None, {})
+        self.group_types = mock.Mock()
+        self.group_types.resource_class = fakes.FakeResource(None, {})
         self.messages = mock.Mock()
         self.messages.resource_class = fakes.FakeResource(None, {})
         self.volumes = mock.Mock()
         self.volumes.resource_class = fakes.FakeResource(None, {})
+        self.volume_types = mock.Mock()
+        self.volume_types.resource_class = fakes.FakeResource(None, {})
 
 
 class TestVolume(utils.TestCommand):
@@ -59,6 +65,111 @@ class TestVolume(utils.TestCommand):
 
 # TODO(stephenfin): Check if the responses are actually the same
 FakeVolume = volume_v2_fakes.FakeVolume
+FakeVolumeType = volume_v2_fakes.FakeVolumeType
+
+
+class FakeVolumeGroup:
+    """Fake one or more volume groups."""
+
+    @staticmethod
+    def create_one_volume_group(attrs=None):
+        """Create a fake group.
+
+        :param attrs: A dictionary with all attributes of group
+        :return: A FakeResource object with id, name, status, etc.
+        """
+        attrs = attrs or {}
+
+        group_type = attrs.pop('group_type', None) or uuid.uuid4().hex
+        volume_types = attrs.pop('volume_types', None) or [uuid.uuid4().hex]
+
+        # Set default attribute
+        group_info = {
+            'id': uuid.uuid4().hex,
+            'status': random.choice([
+                'available',
+            ]),
+            'availability_zone': f'az-{uuid.uuid4().hex}',
+            'created_at': '2015-09-16T09:28:52.000000',
+            'name': 'first_group',
+            'description': f'description-{uuid.uuid4().hex}',
+            'group_type': group_type,
+            'volume_types': volume_types,
+            'volumes': [f'volume-{uuid.uuid4().hex}'],
+            'group_snapshot_id': None,
+            'source_group_id': None,
+            'project_id': f'project-{uuid.uuid4().hex}',
+        }
+
+        # Overwrite default attributes if there are some attributes set
+        group_info.update(attrs)
+
+        group = fakes.FakeResource(
+            None,
+            group_info,
+            loaded=True)
+        return group
+
+    @staticmethod
+    def create_volume_groups(attrs=None, count=2):
+        """Create multiple fake groups.
+
+        :param attrs: A dictionary with all attributes of group
+        :param count: The number of groups to be faked
+        :return: A list of FakeResource objects
+        """
+        groups = []
+        for n in range(0, count):
+            groups.append(FakeVolumeGroup.create_one_volume_group(attrs))
+
+        return groups
+
+
+class FakeVolumeGroupType:
+    """Fake one or more volume group types."""
+
+    @staticmethod
+    def create_one_volume_group_type(attrs=None):
+        """Create a fake group type.
+
+        :param attrs: A dictionary with all attributes of group type
+        :return: A FakeResource object with id, name, description, etc.
+        """
+        attrs = attrs or {}
+
+        # Set default attribute
+        group_type_info = {
+            'id': uuid.uuid4().hex,
+            'name': f'group-type-{uuid.uuid4().hex}',
+            'description': f'description-{uuid.uuid4().hex}',
+            'is_public': random.choice([True, False]),
+            'group_specs': {},
+        }
+
+        # Overwrite default attributes if there are some attributes set
+        group_type_info.update(attrs)
+
+        group_type = fakes.FakeResource(
+            None,
+            group_type_info,
+            loaded=True)
+        return group_type
+
+    @staticmethod
+    def create_volume_group_types(attrs=None, count=2):
+        """Create multiple fake group types.
+
+        :param attrs: A dictionary with all attributes of group type
+        :param count: The number of group types to be faked
+        :return: A list of FakeResource objects
+        """
+        group_types = []
+        for n in range(0, count):
+            group_types.append(
+                FakeVolumeGroupType.create_one_volume_group_type(attrs)
+            )
+
+        return group_types
 
 
 class FakeVolumeMessage:
diff --git a/openstackclient/tests/unit/volume/v3/test_volume_group.py b/openstackclient/tests/unit/volume/v3/test_volume_group.py
new file mode 100644
index 0000000000..13ef38d208
--- /dev/null
+++ b/openstackclient/tests/unit/volume/v3/test_volume_group.py
@@ -0,0 +1,497 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from cinderclient import api_versions
+from osc_lib import exceptions
+
+from openstackclient.tests.unit.volume.v3 import fakes as volume_fakes
+from openstackclient.volume.v3 import volume_group
+
+
+class TestVolumeGroup(volume_fakes.TestVolume):
+
+    def setUp(self):
+        super().setUp()
+
+        self.volume_groups_mock = self.app.client_manager.volume.groups
+        self.volume_groups_mock.reset_mock()
+
+        self.volume_group_types_mock = \
+            self.app.client_manager.volume.group_types
+        self.volume_group_types_mock.reset_mock()
+
+        self.volume_types_mock = self.app.client_manager.volume.volume_types
+        self.volume_types_mock.reset_mock()
+
+
+class TestVolumeGroupCreate(TestVolumeGroup):
+
+    fake_volume_type = volume_fakes.FakeVolumeType.create_one_volume_type()
+    fake_volume_group_type = \
+        volume_fakes.FakeVolumeGroupType.create_one_volume_group_type()
+    fake_volume_group = volume_fakes.FakeVolumeGroup.create_one_volume_group(
+        attrs={
+            'group_type': fake_volume_group_type.id,
+            'volume_types': [fake_volume_type.id],
+        },
+    )
+
+    columns = (
+        'ID',
+        'Status',
+        'Name',
+        'Description',
+        'Group Type',
+        'Volume Types',
+        'Availability Zone',
+        'Created At',
+        'Volumes',
+        'Group Snapshot ID',
+        'Source Group ID',
+    )
+    data = (
+        fake_volume_group.id,
+        fake_volume_group.status,
+        fake_volume_group.name,
+        fake_volume_group.description,
+        fake_volume_group.group_type,
+        fake_volume_group.volume_types,
+        fake_volume_group.availability_zone,
+        fake_volume_group.created_at,
+        fake_volume_group.volumes,
+        fake_volume_group.group_snapshot_id,
+        fake_volume_group.source_group_id,
+    )
+
+    def setUp(self):
+        super().setUp()
+
+        self.volume_types_mock.get.return_value = self.fake_volume_type
+        self.volume_group_types_mock.get.return_value = \
+            self.fake_volume_group_type
+        self.volume_groups_mock.create.return_value = self.fake_volume_group
+        self.volume_groups_mock.get.return_value = self.fake_volume_group
+
+        self.cmd = volume_group.CreateVolumeGroup(self.app, None)
+
+    def test_volume_group_create(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.13')
+
+        arglist = [
+            self.fake_volume_group_type.id,
+            self.fake_volume_type.id,
+        ]
+        verifylist = [
+            ('volume_group_type', self.fake_volume_group_type.id),
+            ('volume_types', [self.fake_volume_type.id]),
+            ('name', None),
+            ('description', None),
+            ('availability_zone', None),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        columns, data = self.cmd.take_action(parsed_args)
+
+        self.volume_group_types_mock.get.assert_called_once_with(
+            self.fake_volume_group_type.id)
+        self.volume_types_mock.get.assert_called_once_with(
+            self.fake_volume_type.id)
+        self.volume_groups_mock.create.assert_called_once_with(
+            self.fake_volume_group_type.id,
+            self.fake_volume_type.id,
+            None,
+            None,
+            availability_zone=None,
+        )
+        self.assertEqual(self.columns, columns)
+        self.assertCountEqual(self.data, data)
+
+    def test_volume_group_create_with_options(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.13')
+
+        arglist = [
+            self.fake_volume_group_type.id,
+            self.fake_volume_type.id,
+            '--name', 'foo',
+            '--description', 'hello, world',
+            '--availability-zone', 'bar',
+        ]
+        verifylist = [
+            ('volume_group_type', self.fake_volume_group_type.id),
+            ('volume_types', [self.fake_volume_type.id]),
+            ('name', 'foo'),
+            ('description', 'hello, world'),
+            ('availability_zone', 'bar'),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        columns, data = self.cmd.take_action(parsed_args)
+
+        self.volume_group_types_mock.get.assert_called_once_with(
+            self.fake_volume_group_type.id)
+        self.volume_types_mock.get.assert_called_once_with(
+            self.fake_volume_type.id)
+        self.volume_groups_mock.create.assert_called_once_with(
+            self.fake_volume_group_type.id,
+            self.fake_volume_type.id,
+            'foo',
+            'hello, world',
+            availability_zone='bar',
+        )
+        self.assertEqual(self.columns, columns)
+        self.assertCountEqual(self.data, data)
+
+    def test_volume_group_create_pre_v313(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.12')
+
+        arglist = [
+            self.fake_volume_group_type.id,
+            self.fake_volume_type.id,
+        ]
+        verifylist = [
+            ('volume_group_type', self.fake_volume_group_type.id),
+            ('volume_types', [self.fake_volume_type.id]),
+            ('name', None),
+            ('description', None),
+            ('availability_zone', None),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        exc = self.assertRaises(
+            exceptions.CommandError,
+            self.cmd.take_action,
+            parsed_args)
+        self.assertIn(
+            '--os-volume-api-version 3.13 or greater is required',
+            str(exc))
+
+
+class TestVolumeGroupDelete(TestVolumeGroup):
+
+    fake_volume_group = \
+        volume_fakes.FakeVolumeGroup.create_one_volume_group()
+
+    def setUp(self):
+        super().setUp()
+
+        self.volume_groups_mock.get.return_value = self.fake_volume_group
+        self.volume_groups_mock.delete.return_value = None
+
+        self.cmd = volume_group.DeleteVolumeGroup(self.app, None)
+
+    def test_volume_group_delete(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.13')
+
+        arglist = [
+            self.fake_volume_group.id,
+            '--force',
+        ]
+        verifylist = [
+            ('group', self.fake_volume_group.id),
+            ('force', True),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        result = self.cmd.take_action(parsed_args)
+
+        self.volume_groups_mock.delete.assert_called_once_with(
+            self.fake_volume_group.id, delete_volumes=True,
+        )
+        self.assertIsNone(result)
+
+    def test_volume_group_delete_pre_v313(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.12')
+
+        arglist = [
+            self.fake_volume_group.id,
+        ]
+        verifylist = [
+            ('group', self.fake_volume_group.id),
+            ('force', False),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        exc = self.assertRaises(
+            exceptions.CommandError,
+            self.cmd.take_action,
+            parsed_args)
+        self.assertIn(
+            '--os-volume-api-version 3.13 or greater is required',
+            str(exc))
+
+
+class TestVolumeGroupSet(TestVolumeGroup):
+
+    fake_volume_group = \
+        volume_fakes.FakeVolumeGroup.create_one_volume_group()
+
+    columns = (
+        'ID',
+        'Status',
+        'Name',
+        'Description',
+        'Group Type',
+        'Volume Types',
+        'Availability Zone',
+        'Created At',
+        'Volumes',
+        'Group Snapshot ID',
+        'Source Group ID',
+    )
+    data = (
+        fake_volume_group.id,
+        fake_volume_group.status,
+        fake_volume_group.name,
+        fake_volume_group.description,
+        fake_volume_group.group_type,
+        fake_volume_group.volume_types,
+        fake_volume_group.availability_zone,
+        fake_volume_group.created_at,
+        fake_volume_group.volumes,
+        fake_volume_group.group_snapshot_id,
+        fake_volume_group.source_group_id,
+    )
+
+    def setUp(self):
+        super().setUp()
+
+        self.volume_groups_mock.get.return_value = self.fake_volume_group
+        self.volume_groups_mock.update.return_value = self.fake_volume_group
+
+        self.cmd = volume_group.SetVolumeGroup(self.app, None)
+
+    def test_volume_group_set(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.13')
+
+        arglist = [
+            self.fake_volume_group.id,
+            '--name', 'foo',
+            '--description', 'hello, world',
+        ]
+        verifylist = [
+            ('group', self.fake_volume_group.id),
+            ('name', 'foo'),
+            ('description', 'hello, world'),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        columns, data = self.cmd.take_action(parsed_args)
+
+        self.volume_groups_mock.update.assert_called_once_with(
+            self.fake_volume_group.id, name='foo', description='hello, world',
+        )
+        self.assertEqual(self.columns, columns)
+        self.assertCountEqual(self.data, data)
+
+    def test_volume_group_with_enable_replication_option(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.38')
+
+        arglist = [
+            self.fake_volume_group.id,
+            '--enable-replication',
+        ]
+        verifylist = [
+            ('group', self.fake_volume_group.id),
+            ('enable_replication', True),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        columns, data = self.cmd.take_action(parsed_args)
+
+        self.volume_groups_mock.enable_replication.assert_called_once_with(
+            self.fake_volume_group.id)
+        self.assertEqual(self.columns, columns)
+        self.assertCountEqual(self.data, data)
+
+    def test_volume_group_set_pre_v313(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.12')
+
+        arglist = [
+            self.fake_volume_group.id,
+            '--name', 'foo',
+            '--description', 'hello, world',
+        ]
+        verifylist = [
+            ('group', self.fake_volume_group.id),
+            ('name', 'foo'),
+            ('description', 'hello, world'),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        exc = self.assertRaises(
+            exceptions.CommandError,
+            self.cmd.take_action,
+            parsed_args)
+        self.assertIn(
+            '--os-volume-api-version 3.13 or greater is required',
+            str(exc))
+
+    def test_volume_group_with_enable_replication_option_pre_v338(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.37')
+
+        arglist = [
+            self.fake_volume_group.id,
+            '--enable-replication',
+        ]
+        verifylist = [
+            ('group', self.fake_volume_group.id),
+            ('enable_replication', True),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        exc = self.assertRaises(
+            exceptions.CommandError,
+            self.cmd.take_action,
+            parsed_args)
+        self.assertIn(
+            '--os-volume-api-version 3.38 or greater is required',
+            str(exc))
+
+
+class TestVolumeGroupList(TestVolumeGroup):
+
+    fake_volume_groups = \
+        volume_fakes.FakeVolumeGroup.create_volume_groups()
+
+    columns = (
+        'ID',
+        'Status',
+        'Name',
+    )
+    data = [
+        (
+            fake_volume_group.id,
+            fake_volume_group.status,
+            fake_volume_group.name,
+        ) for fake_volume_group in fake_volume_groups
+    ]
+
+    def setUp(self):
+        super().setUp()
+
+        self.volume_groups_mock.list.return_value = self.fake_volume_groups
+
+        self.cmd = volume_group.ListVolumeGroup(self.app, None)
+
+    def test_volume_group_list(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.13')
+
+        arglist = [
+            '--all-projects',
+        ]
+        verifylist = [
+            ('all_projects', True),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        columns, data = self.cmd.take_action(parsed_args)
+
+        self.volume_groups_mock.list.assert_called_once_with(
+            search_opts={
+                'all_tenants': True,
+            },
+        )
+        self.assertEqual(self.columns, columns)
+        self.assertCountEqual(tuple(self.data), data)
+
+    def test_volume_group_list_pre_v313(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.12')
+
+        arglist = [
+            '--all-projects',
+        ]
+        verifylist = [
+            ('all_projects', True),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        exc = self.assertRaises(
+            exceptions.CommandError,
+            self.cmd.take_action,
+            parsed_args)
+        self.assertIn(
+            '--os-volume-api-version 3.13 or greater is required',
+            str(exc))
+
+
+class TestVolumeGroupFailover(TestVolumeGroup):
+
+    fake_volume_group = \
+        volume_fakes.FakeVolumeGroup.create_one_volume_group()
+
+    def setUp(self):
+        super().setUp()
+
+        self.volume_groups_mock.get.return_value = self.fake_volume_group
+        self.volume_groups_mock.failover_replication.return_value = None
+
+        self.cmd = volume_group.FailoverVolumeGroup(self.app, None)
+
+    def test_volume_group_failover(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.38')
+
+        arglist = [
+            self.fake_volume_group.id,
+            '--allow-attached-volume',
+            '--secondary-backend-id', 'foo',
+        ]
+        verifylist = [
+            ('group', self.fake_volume_group.id),
+            ('allow_attached_volume', True),
+            ('secondary_backend_id', 'foo'),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        result = self.cmd.take_action(parsed_args)
+
+        self.volume_groups_mock.failover_replication.assert_called_once_with(
+            self.fake_volume_group.id,
+            allow_attached_volume=True,
+            secondary_backend_id='foo',
+        )
+        self.assertIsNone(result)
+
+    def test_volume_group_failover_pre_v338(self):
+        self.app.client_manager.volume.api_version = \
+            api_versions.APIVersion('3.37')
+
+        arglist = [
+            self.fake_volume_group.id,
+            '--allow-attached-volume',
+            '--secondary-backend-id', 'foo',
+        ]
+        verifylist = [
+            ('group', self.fake_volume_group.id),
+            ('allow_attached_volume', True),
+            ('secondary_backend_id', 'foo'),
+        ]
+        parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+        exc = self.assertRaises(
+            exceptions.CommandError,
+            self.cmd.take_action,
+            parsed_args)
+        self.assertIn(
+            '--os-volume-api-version 3.38 or greater is required',
+            str(exc))
diff --git a/openstackclient/volume/v3/volume_group.py b/openstackclient/volume/v3/volume_group.py
new file mode 100644
index 0000000000..db4e9a94fa
--- /dev/null
+++ b/openstackclient/volume/v3/volume_group.py
@@ -0,0 +1,506 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import logging
+
+from cinderclient import api_versions
+from osc_lib.command import command
+from osc_lib import exceptions
+from osc_lib import utils
+
+from openstackclient.i18n import _
+
+LOG = logging.getLogger(__name__)
+
+
+def _format_group(group):
+    columns = (
+        'id',
+        'status',
+        'name',
+        'description',
+        'group_type',
+        'volume_types',
+        'availability_zone',
+        'created_at',
+        'volumes',
+        'group_snapshot_id',
+        'source_group_id',
+    )
+    column_headers = (
+        'ID',
+        'Status',
+        'Name',
+        'Description',
+        'Group Type',
+        'Volume Types',
+        'Availability Zone',
+        'Created At',
+        'Volumes',
+        'Group Snapshot ID',
+        'Source Group ID',
+    )
+
+    # TODO(stephenfin): Consider using a formatter for volume_types since it's
+    # a list
+    return (
+        column_headers,
+        utils.get_item_properties(
+            group,
+            columns,
+        ),
+    )
+
+
+class CreateVolumeGroup(command.ShowOne):
+    """Create a volume group.
+
+    Generic volume groups enable you to create a group of volumes and manage
+    them together.
+
+    Generic volume groups are more flexible than consistency groups. Currently
+    volume consistency groups only support consistent group snapshot. It
+    cannot be extended easily to serve other purposes. A project may want to
+    put volumes used in the same application together in a group so that it is
+    easier to manage them together, and this group of volumes may or may not
+    support consistent group snapshot. Generic volume group solve this problem.
+    By decoupling the tight relationship between the group construct and the
+    consistency concept, generic volume groups can be extended to support other
+    features in the future.
+
+    This command requires ``--os-volume-api-version`` 3.13 or greater.
+    """
+
+    def get_parser(self, prog_name):
+        parser = super().get_parser(prog_name)
+        parser.add_argument(
+            'volume_group_type',
+            metavar='<volume_group_type>',
+            help=_('Name or ID of volume group type to use.'),
+        )
+        parser.add_argument(
+            'volume_types',
+            metavar='<volume_type>',
+            nargs='+',
+            default=[],
+            help=_('Name or ID of volume type(s) to use.'),
+        )
+        parser.add_argument(
+            '--name',
+            metavar='<name>',
+            help=_('Name of the volume group.'),
+        )
+        parser.add_argument(
+            '--description',
+            metavar='<description>',
+            help=_('Description of a volume group.')
+        )
+        parser.add_argument(
+            '--availability-zone',
+            metavar='<availability-zone>',
+            help=_('Availability zone for volume group.'),
+        )
+        return parser
+
+    def take_action(self, parsed_args):
+        volume_client = self.app.client_manager.volume
+
+        if volume_client.api_version < api_versions.APIVersion('3.13'):
+            msg = _(
+                "--os-volume-api-version 3.13 or greater is required to "
+                "support the 'volume group create' command"
+            )
+            raise exceptions.CommandError(msg)
+
+        volume_group_type = utils.find_resource(
+            volume_client.group_types,
+            parsed_args.volume_group_type,
+        )
+
+        volume_types = []
+        for volume_type in parsed_args.volume_types:
+            volume_types.append(
+                utils.find_resource(
+                    volume_client.volume_types,
+                    volume_type,
+                )
+            )
+
+        group = volume_client.groups.create(
+            volume_group_type.id,
+            ','.join(x.id for x in volume_types),
+            parsed_args.name,
+            parsed_args.description,
+            availability_zone=parsed_args.availability_zone)
+
+        group = volume_client.groups.get(group.id)
+
+        return _format_group(group)
+
+
+class DeleteVolumeGroup(command.Command):
+    """Delete a volume group.
+
+    This command requires ``--os-volume-api-version`` 3.13 or greater.
+    """
+
+    def get_parser(self, prog_name):
+        parser = super().get_parser(prog_name)
+        parser.add_argument(
+            'group',
+            metavar='<group>',
+            help=_('Name or ID of volume group to delete'),
+        )
+        parser.add_argument(
+            '--force',
+            action='store_true',
+            default=False,
+            help=_(
+                'Delete the volume group even if it contains volumes. '
+                'This will delete any remaining volumes in the group.',
+            )
+        )
+        return parser
+
+    def take_action(self, parsed_args):
+        volume_client = self.app.client_manager.volume
+
+        if volume_client.api_version < api_versions.APIVersion('3.13'):
+            msg = _(
+                "--os-volume-api-version 3.13 or greater is required to "
+                "support the 'volume group delete' command"
+            )
+            raise exceptions.CommandError(msg)
+
+        group = utils.find_resource(
+            volume_client.groups,
+            parsed_args.group,
+        )
+
+        volume_client.groups.delete(
+            group.id, delete_volumes=parsed_args.force)
+
+
+class SetVolumeGroup(command.ShowOne):
+    """Update a volume group.
+
+    This command requires ``--os-volume-api-version`` 3.13 or greater.
+    """
+
+    def get_parser(self, prog_name):
+        parser = super().get_parser(prog_name)
+        parser.add_argument(
+            'group',
+            metavar='<group>',
+            help=_('Name or ID of volume group.'),
+        )
+        parser.add_argument(
+            '--name',
+            metavar='<name>',
+            help=_('New name for group.'),
+        )
+        parser.add_argument(
+            '--description',
+            metavar='<description>',
+            help=_('New description for group.'),
+        )
+        parser.add_argument(
+            '--enable-replication',
+            action='store_true',
+            dest='enable_replication',
+            default=None,
+            help=_(
+                'Enable replication for group. '
+                '(supported by --os-volume-api-version 3.38 or above)'
+            ),
+        )
+        parser.add_argument(
+            '--disable-replication',
+            action='store_false',
+            dest='enable_replication',
+            help=_(
+                'Disable replication for group. '
+                '(supported by --os-volume-api-version 3.38 or above)'
+            ),
+        )
+        return parser
+
+    def take_action(self, parsed_args):
+        volume_client = self.app.client_manager.volume
+
+        if volume_client.api_version < api_versions.APIVersion('3.13'):
+            msg = _(
+                "--os-volume-api-version 3.13 or greater is required to "
+                "support the 'volume group set' command"
+            )
+            raise exceptions.CommandError(msg)
+
+        group = utils.find_resource(
+            volume_client.groups,
+            parsed_args.group,
+        )
+
+        if parsed_args.enable_replication is not None:
+            if volume_client.api_version < api_versions.APIVersion('3.38'):
+                msg = _(
+                    "--os-volume-api-version 3.38 or greater is required to "
+                    "support the '--enable-replication' or "
+                    "'--disable-replication' options"
+                )
+                raise exceptions.CommandError(msg)
+
+            if parsed_args.enable_replication:
+                volume_client.groups.enable_replication(group.id)
+            else:
+                volume_client.groups.disable_replication(group.id)
+
+        kwargs = {}
+
+        if parsed_args.name is not None:
+            kwargs['name'] = parsed_args.name
+
+        if parsed_args.description is not None:
+            kwargs['description'] = parsed_args.description
+
+        if kwargs:
+            group = volume_client.groups.update(group.id, **kwargs)
+
+        return _format_group(group)
+
+
+class ListVolumeGroup(command.Lister):
+    """Lists all volume groups.
+
+    This command requires ``--os-volume-api-version`` 3.13 or greater.
+    """
+
+    def get_parser(self, prog_name):
+        parser = super().get_parser(prog_name)
+        parser.add_argument(
+            '--all-projects',
+            dest='all_projects',
+            action='store_true',
+            default=utils.env('ALL_PROJECTS', default=False),
+            help=_('Shows details for all projects (admin only).'),
+        )
+        # TODO(stephenfin): Add once we have an equivalent command for
+        # 'cinder list-filters'
+        # parser.add_argument(
+        #     '--filter',
+        #     metavar='<key=value>',
+        #     action=parseractions.KeyValueAction,
+        #     dest='filters',
+        #     help=_(
+        #         "Filter key and value pairs. Use 'foo' to "
+        #         "check enabled filters from server. Use 'key~=value' for "
+        #         "inexact filtering if the key supports "
+        #         "(supported by --os-volume-api-version 3.33 or above)"
+        #     ),
+        # )
+        return parser
+
+    def take_action(self, parsed_args):
+        volume_client = self.app.client_manager.volume
+
+        if volume_client.api_version < api_versions.APIVersion('3.13'):
+            msg = _(
+                "--os-volume-api-version 3.13 or greater is required to "
+                "support the 'volume group list' command"
+            )
+            raise exceptions.CommandError(msg)
+
+        search_opts = {
+            'all_tenants': parsed_args.all_projects,
+        }
+
+        groups = volume_client.groups.list(
+            search_opts=search_opts)
+
+        column_headers = (
+            'ID',
+            'Status',
+            'Name',
+        )
+        columns = (
+            'id',
+            'status',
+            'name',
+        )
+
+        return (
+            column_headers,
+            (
+                utils.get_item_properties(a, columns)
+                for a in groups
+            ),
+        )
+
+
+class ShowVolumeGroup(command.ShowOne):
+    """Show detailed information for a volume group.
+
+    This command requires ``--os-volume-api-version`` 3.13 or greater.
+    """
+
+    def get_parser(self, prog_name):
+        parser = super().get_parser(prog_name)
+        parser.add_argument(
+            'group',
+            metavar='<group>',
+            help=_('Name or ID of volume group.'),
+        )
+        parser.add_argument(
+            '--volumes',
+            action='store_true',
+            dest='show_volumes',
+            default=None,
+            help=_(
+                'Show volumes included in the group. '
+                '(supported by --os-volume-api-version 3.25 or above)'
+            ),
+        )
+        parser.add_argument(
+            '--no-volumes',
+            action='store_false',
+            dest='show_volumes',
+            help=_(
+                'Do not show volumes included in the group. '
+                '(supported by --os-volume-api-version 3.25 or above)'
+            ),
+        )
+        parser.add_argument(
+            '--replication-targets',
+            action='store_true',
+            dest='show_replication_targets',
+            default=None,
+            help=_(
+                'Show replication targets for the group. '
+                '(supported by --os-volume-api-version 3.38 or above)'
+            ),
+        )
+        parser.add_argument(
+            '--no-replication-targets',
+            action='store_false',
+            dest='show_replication_targets',
+            help=_(
+                'Do not show replication targets for the group. '
+                '(supported by --os-volume-api-version 3.38 or above)'
+            ),
+        )
+
+        return parser
+
+    def take_action(self, parsed_args):
+        volume_client = self.app.client_manager.volume
+
+        if volume_client.api_version < api_versions.APIVersion('3.13'):
+            msg = _(
+                "--os-volume-api-version 3.13 or greater is required to "
+                "support the 'volume group show' command"
+            )
+            raise exceptions.CommandError(msg)
+
+        kwargs = {}
+
+        if parsed_args.show_volumes is not None:
+            if volume_client.api_version < api_versions.APIVersion('3.25'):
+                msg = _(
+                    "--os-volume-api-version 3.25 or greater is required to "
+                    "support the '--(no-)volumes' option"
+                )
+                raise exceptions.CommandError(msg)
+
+            kwargs['list_volume'] = parsed_args.show_volumes
+
+        if parsed_args.show_replication_targets is not None:
+            if volume_client.api_version < api_versions.APIVersion('3.38'):
+                msg = _(
+                    "--os-volume-api-version 3.38 or greater is required to "
+                    "support the '--(no-)replication-targets' option"
+                )
+                raise exceptions.CommandError(msg)
+
+        group = utils.find_resource(
+            volume_client.groups,
+            parsed_args.group,
+        )
+
+        group = volume_client.groups.show(group.id, **kwargs)
+
+        if parsed_args.show_replication_targets:
+            replication_targets = \
+                volume_client.groups.list_replication_targets(group.id)
+
+            group.replication_targets = replication_targets
+
+        # TODO(stephenfin): Show replication targets
+        return _format_group(group)
+
+
+class FailoverVolumeGroup(command.Command):
+    """Failover replication for a volume group.
+
+    This command requires ``--os-volume-api-version`` 3.38 or greater.
+    """
+
+    def get_parser(self, prog_name):
+        parser = super().get_parser(prog_name)
+        parser.add_argument(
+            'group',
+            metavar='<group>',
+            help=_('Name or ID of volume group to failover replication for.'),
+        )
+        parser.add_argument(
+            '--allow-attached-volume',
+            action='store_true',
+            dest='allow_attached_volume',
+            default=False,
+            help=_(
+                'Allow group with attached volumes to be failed over.',
+            )
+        )
+        parser.add_argument(
+            '--disallow-attached-volume',
+            action='store_false',
+            dest='allow_attached_volume',
+            default=False,
+            help=_(
+                'Disallow group with attached volumes to be failed over.',
+            )
+        )
+        parser.add_argument(
+            '--secondary-backend-id',
+            metavar='<backend_id>',
+            help=_('Secondary backend ID.'),
+        )
+        return parser
+
+    def take_action(self, parsed_args):
+        volume_client = self.app.client_manager.volume
+
+        if volume_client.api_version < api_versions.APIVersion('3.38'):
+            msg = _(
+                "--os-volume-api-version 3.38 or greater is required to "
+                "support the 'volume group failover' command"
+            )
+            raise exceptions.CommandError(msg)
+
+        group = utils.find_resource(
+            volume_client.groups,
+            parsed_args.group,
+        )
+
+        volume_client.groups.failover_replication(
+            group.id,
+            allow_attached_volume=parsed_args.allow_attached_volume,
+            secondary_backend_id=parsed_args.secondary_backend_id,
+        )
diff --git a/releasenotes/notes/add-volume-group-commands-b121d6ec7da9779a.yaml b/releasenotes/notes/add-volume-group-commands-b121d6ec7da9779a.yaml
new file mode 100644
index 0000000000..8b3fe7ecc4
--- /dev/null
+++ b/releasenotes/notes/add-volume-group-commands-b121d6ec7da9779a.yaml
@@ -0,0 +1,8 @@
+---
+features:
+  - |
+    Add ``volume group create``, ``volume group delete``,
+    ``volume group list``, ``volume group failover``,
+    ``volume group set/unset`` and ``volume attachment show``
+    commands to create, delete, list, failover, update and show volume groups,
+    respectively.
diff --git a/setup.cfg b/setup.cfg
index b3909f3a21..a462e0d37c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -720,6 +720,13 @@ openstack.volume.v3 =
     volume_backup_record_export = openstackclient.volume.v2.backup_record:ExportBackupRecord
     volume_backup_record_import = openstackclient.volume.v2.backup_record:ImportBackupRecord
 
+    volume_group_create = openstackclient.volume.v3.volume_group:CreateVolumeGroup
+    volume_group_delete = openstackclient.volume.v3.volume_group:DeleteVolumeGroup
+    volume_group_list = openstackclient.volume.v3.volume_group:ListVolumeGroup
+    volume_group_failover = openstackclient.volume.v3.volume_group:FailoverVolumeGroup
+    volume_group_set = openstackclient.volume.v3.volume_group:SetVolumeGroup
+    volume_group_show = openstackclient.volume.v3.volume_group:ShowVolumeGroup
+
     volume_host_set = openstackclient.volume.v2.volume_host:SetVolumeHost
 
     volume_message_delete = openstackclient.volume.v3.volume_message:DeleteMessage