diff --git a/plugins/module_utils/user.py b/plugins/module_utils/user.py
index dbc1c9b..b2de38d 100644
--- a/plugins/module_utils/user.py
+++ b/plugins/module_utils/user.py
@@ -10,6 +10,7 @@ __metaclass__ = type
 # Simplified BSD License (see simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)
 
 import string
+import json
 import re
 
 from ansible.module_utils.six import iteritems
@@ -151,14 +152,20 @@ def get_existing_authentication(cursor, user, host):
 
 def user_add(cursor, user, host, host_all, password, encrypted,
              plugin, plugin_hash_string, plugin_auth_string, new_priv,
-             tls_requires, check_mode, reuse_existing_password):
+             attributes, tls_requires, reuse_existing_password, module):
     # we cannot create users without a proper hostname
     if host_all:
         return {'changed': False, 'password_changed': False}
 
-    if check_mode:
+    if module.check_mode:
         return {'changed': True, 'password_changed': None}
 
+    # If attributes are set, perform a sanity check to ensure server supports user attributes before creating user
+    if attributes and not get_attribute_support(cursor):
+        module.fail_json(msg="user attributes were specified but the mysql server does not support user attributes")
+
+    final_attributes = {}
+
     # Determine what user management method server uses
     old_user_mgmt = impl.use_old_user_mgmt(cursor)
 
@@ -203,9 +210,13 @@ def user_add(cursor, user, host, host_all, password, encrypted,
     if new_priv is not None:
         for db_table, priv in iteritems(new_priv):
             privileges_grant(cursor, user, host, db_table, priv, tls_requires)
+    if attributes is not None:
+        cursor.execute("ALTER USER %s@%s ATTRIBUTE %s", (user, host, json.dumps(attributes)))
+        final_attributes = attributes_get(cursor, user, host)
     if tls_requires is not None:
         privileges_grant(cursor, user, host, "*.*", get_grants(cursor, user, host), tls_requires)
-    return {'changed': True, 'password_changed': not used_existing_password}
+
+    return {'changed': True, 'password_changed': not used_existing_password, 'attributes': final_attributes}
 
 
 def is_hash(password):
@@ -218,7 +229,7 @@ def is_hash(password):
 
 def user_mod(cursor, user, host, host_all, password, encrypted,
              plugin, plugin_hash_string, plugin_auth_string, new_priv,
-             append_privs, subtract_privs, tls_requires, module, role=False, maria_role=False):
+             append_privs, subtract_privs, attributes, tls_requires, module, role=False, maria_role=False):
     changed = False
     msg = "User unchanged"
     grant_option = False
@@ -278,27 +289,26 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
                 if current_pass_hash != encrypted_password:
                     password_changed = True
                     msg = "Password updated"
-                    if module.check_mode:
-                        return {'changed': True, 'msg': msg, 'password_changed': password_changed}
-                    if old_user_mgmt:
-                        cursor.execute("SET PASSWORD FOR %s@%s = %s", (user, host, encrypted_password))
-                        msg = "Password updated (old style)"
-                    else:
-                        try:
-                            cursor.execute("ALTER USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", (user, host, encrypted_password))
-                            msg = "Password updated (new style)"
-                        except (mysql_driver.Error) as e:
-                            # https://stackoverflow.com/questions/51600000/authentication-string-of-root-user-on-mysql
-                            # Replacing empty root password with new authentication mechanisms fails with error 1396
-                            if e.args[0] == 1396:
-                                cursor.execute(
-                                    "UPDATE mysql.user SET plugin = %s, authentication_string = %s, Password = '' WHERE User = %s AND Host = %s",
-                                    ('mysql_native_password', encrypted_password, user, host)
-                                )
-                                cursor.execute("FLUSH PRIVILEGES")
-                                msg = "Password forced update"
-                            else:
-                                raise e
+                    if not module.check_mode:
+                        if old_user_mgmt:
+                            cursor.execute("SET PASSWORD FOR %s@%s = %s", (user, host, encrypted_password))
+                            msg = "Password updated (old style)"
+                        else:
+                            try:
+                                cursor.execute("ALTER USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", (user, host, encrypted_password))
+                                msg = "Password updated (new style)"
+                            except (mysql_driver.Error) as e:
+                                # https://stackoverflow.com/questions/51600000/authentication-string-of-root-user-on-mysql
+                                # Replacing empty root password with new authentication mechanisms fails with error 1396
+                                if e.args[0] == 1396:
+                                    cursor.execute(
+                                        "UPDATE mysql.user SET plugin = %s, authentication_string = %s, Password = '' WHERE User = %s AND Host = %s",
+                                        ('mysql_native_password', encrypted_password, user, host)
+                                    )
+                                    cursor.execute("FLUSH PRIVILEGES")
+                                    msg = "Password forced update"
+                                else:
+                                    raise e
                     changed = True
 
         # Handle plugin authentication
@@ -352,9 +362,8 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
                     if db_table not in new_priv:
                         if user != "root" and "PROXY" not in priv:
                             msg = "Privileges updated"
-                            if module.check_mode:
-                                return {'changed': True, 'msg': msg, 'password_changed': password_changed}
-                            privileges_revoke(cursor, user, host, db_table, priv, grant_option, maria_role)
+                            if not module.check_mode:
+                                privileges_revoke(cursor, user, host, db_table, priv, grant_option, maria_role)
                             changed = True
 
             # If the user doesn't currently have any privileges on a db.table, then
@@ -363,9 +372,8 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
                 for db_table, priv in iteritems(new_priv):
                     if db_table not in curr_priv:
                         msg = "New privileges granted"
-                        if module.check_mode:
-                            return {'changed': True, 'msg': msg, 'password_changed': password_changed}
-                        privileges_grant(cursor, user, host, db_table, priv, tls_requires, maria_role)
+                        if not module.check_mode:
+                            privileges_grant(cursor, user, host, db_table, priv, tls_requires, maria_role)
                         changed = True
 
             # If the db.table specification exists in both the user's current privileges
@@ -404,17 +412,42 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
 
                 if len(grant_privs) + len(revoke_privs) > 0:
                     msg = "Privileges updated: granted %s, revoked %s" % (grant_privs, revoke_privs)
-                    if module.check_mode:
-                        return {'changed': True, 'msg': msg, 'password_changed': password_changed}
-                    if len(revoke_privs) > 0:
-                        privileges_revoke(cursor, user, host, db_table, revoke_privs, grant_option, maria_role)
-                    if len(grant_privs) > 0:
-                        privileges_grant(cursor, user, host, db_table, grant_privs, tls_requires, maria_role)
+                    if not module.check_mode:
+                        if len(revoke_privs) > 0:
+                            privileges_revoke(cursor, user, host, db_table, revoke_privs, grant_option, maria_role)
+                        if len(grant_privs) > 0:
+                            privileges_grant(cursor, user, host, db_table, grant_privs, tls_requires, maria_role)
 
             # after privilege manipulation, compare privileges from before and now
             after_priv = privileges_get(cursor, user, host, maria_role)
             changed = changed or (curr_priv != after_priv)
 
+        # Handle attributes
+        attribute_support = get_attribute_support(cursor)
+
+        if attributes:
+            if not attribute_support:
+                module.fail_json(msg="user attributes were specified but the mysql server does not support user attributes")
+            else:
+                current_attributes = attributes_get(cursor, user, host)
+                attributes_to_change = {}
+
+                for key, value in attributes.items():
+                    if key not in current_attributes or current_attributes[key] != value:
+                        # The mysql null value (None in python) is used to delete an attribute; we use False to represent None in the attributes parameter
+                        attributes_to_change[key] = None if not value else value
+
+                if attributes_to_change:
+                    msg = "Attributes updated: %s" % (", ".join(["%s: %s" % (key, value) for key, value in attributes_to_change.items()]))
+                    if not module.check_mode:
+                        cursor.execute("ALTER USER %s@%s ATTRIBUTE %s", (user, host, json.dumps(attributes_to_change)))
+                    changed = True
+
+        if attribute_support:
+            final_attributes = attributes_get(cursor, user, host)
+        else:
+            final_attributes = {}
+
         if role:
             continue
 
@@ -422,24 +455,23 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
         current_requires = get_tls_requires(cursor, user, host)
         if current_requires != tls_requires:
             msg = "TLS requires updated"
-            if module.check_mode:
-                return {'changed': True, 'msg': msg, 'password_changed': password_changed}
-            if not old_user_mgmt:
-                pre_query = "ALTER USER"
-            else:
-                pre_query = "GRANT %s ON *.* TO" % ",".join(get_grants(cursor, user, host))
+            if not module.check_mode:
+                if not old_user_mgmt:
+                    pre_query = "ALTER USER"
+                else:
+                    pre_query = "GRANT %s ON *.* TO" % ",".join(get_grants(cursor, user, host))
 
-            if tls_requires is not None:
-                query = " ".join((pre_query, "%s@%s"))
-                query_with_args = mogrify_requires(query, (user, host), tls_requires)
-            else:
-                query = " ".join((pre_query, "%s@%s REQUIRE NONE"))
-                query_with_args = query, (user, host)
+                if tls_requires is not None:
+                    query = " ".join((pre_query, "%s@%s"))
+                    query_with_args = mogrify_requires(query, (user, host), tls_requires)
+                else:
+                    query = " ".join((pre_query, "%s@%s REQUIRE NONE"))
+                    query_with_args = query, (user, host)
 
-            cursor.execute(*query_with_args)
+                cursor.execute(*query_with_args)
             changed = True
 
-    return {'changed': changed, 'msg': msg, 'password_changed': password_changed}
+    return {'changed': changed, 'msg': msg, 'password_changed': password_changed, 'attributes': final_attributes}
 
 
 def user_delete(cursor, user, host, host_all, check_mode):
@@ -924,6 +956,49 @@ def limit_resources(module, cursor, user, host, resource_limits, check_mode):
     return True
 
 
+def get_attribute_support(cursor):
+    """Checks if the MySQL server supports user attributes.
+
+    Args:
+        cursor (cursor): DB driver cursor object.
+    Returns:
+        True if attributes are supported, False if they are not.
+    """
+
+    try:
+        # information_schema.tables does not hold the tables within information_schema itself
+        cursor.execute("SELECT attribute FROM INFORMATION_SCHEMA.USER_ATTRIBUTES LIMIT 0")
+        cursor.fetchone()
+    except mysql_driver.OperationalError:
+        return False
+
+    return True
+
+def attributes_get(cursor, user, host):
+    """Get attributes for a given user.
+
+    Args:
+        cursor (cursor): DB driver cursor object.
+        user (str): User name.
+        host (str): User host name.
+
+    Returns:
+        None if the user does not exist, otherwise a dict of attributes set on the user
+    """
+    cursor.execute("SELECT attribute FROM INFORMATION_SCHEMA.USER_ATTRIBUTES WHERE user = %s AND host = %s", (user, host))
+
+    r = cursor.fetchone()
+
+    if r:
+        attributes = r[0]
+        # convert JSON string stored in row into a dict - mysql enforces that user_attributes entires are in JSON format
+        if attributes:
+            return json.loads(attributes)
+        else:
+            return {}
+
+    return None
+
 def get_impl(cursor):
     global impl
     cursor.execute("SELECT VERSION()")
diff --git a/plugins/modules/mysql_user.py b/plugins/modules/mysql_user.py
index 3e914e6..1d4aad3 100644
--- a/plugins/modules/mysql_user.py
+++ b/plugins/modules/mysql_user.py
@@ -155,7 +155,6 @@ options:
       - Cannot be used to set global variables, use the M(community.mysql.mysql_variables) module instead.
     type: dict
     version_added: '3.6.0'
-
   column_case_sensitive:
     description:
       - The default is C(false).
@@ -165,6 +164,13 @@ options:
         fields names in privileges.
     type: bool
     version_added: '3.8.0'
+  attributes:
+    description:
+      - Create, update, or delete user attributes (arbitrary "key: value" comments) for the user.
+      - MySQL server must support the INFORMATION_SCHEMA.USER_ATTRIBUTES table. Provided since MySQL 8.0.
+      - To delete an existing attribute, set its value to False.
+    type: dict
+    version_added: '3.9.0'
 
 notes:
    - "MySQL server installs with default I(login_user) of C(root) and no password.
@@ -257,6 +263,13 @@ EXAMPLES = r'''
       FUNCTION my_db.my_function: EXECUTE
     state: present
 
+- name: Modify user attributes, creating the attribute 'foo' and removing the attribute 'bar'
+  community.mysql.mysql_user:
+    name: bob
+    attributes:
+      foo: "foo"
+      bar: False
+
 - name: Modify user to require TLS connection with a valid client certificate
   community.mysql.mysql_user:
     name: bob
@@ -405,6 +418,7 @@ def main():
         tls_requires=dict(type='dict'),
         append_privs=dict(type='bool', default=False),
         subtract_privs=dict(type='bool', default=False),
+        attributes=dict(type='dict'),
         check_implicit_admin=dict(type='bool', default=False),
         update_password=dict(type='str', default='always', choices=['always', 'on_create', 'on_new_username'], no_log=False),
         sql_log_bin=dict(type='bool', default=True),
@@ -437,6 +451,7 @@ def main():
     append_privs = module.boolean(module.params["append_privs"])
     subtract_privs = module.boolean(module.params['subtract_privs'])
     update_password = module.params['update_password']
+    attributes = module.params['attributes']
     ssl_cert = module.params["client_cert"]
     ssl_key = module.params["client_key"]
     ssl_ca = module.params["ca_cert"]
@@ -500,21 +515,23 @@ def main():
 
         priv = privileges_unpack(priv, mode, column_case_sensitive, ensure_usage=not subtract_privs)
     password_changed = False
+    final_attributes = {}
     if state == "present":
         if user_exists(cursor, user, host, host_all):
             try:
                 if update_password == "always":
                     result = user_mod(cursor, user, host, host_all, password, encrypted,
                                       plugin, plugin_hash_string, plugin_auth_string,
-                                      priv, append_privs, subtract_privs, tls_requires, module)
+                                      priv, append_privs, subtract_privs, attributes, tls_requires, module)
 
                 else:
                     result = user_mod(cursor, user, host, host_all, None, encrypted,
                                       None, None, None,
-                                      priv, append_privs, subtract_privs, tls_requires, module)
+                                      priv, append_privs, subtract_privs, attributes, tls_requires, module)
                 changed = result['changed']
                 msg = result['msg']
                 password_changed = result['password_changed']
+                final_attributes = result['attributes']
 
             except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e:
                 module.fail_json(msg=to_native(e))
@@ -527,9 +544,10 @@ def main():
                 reuse_existing_password = update_password == 'on_new_username'
                 result = user_add(cursor, user, host, host_all, password, encrypted,
                                   plugin, plugin_hash_string, plugin_auth_string,
-                                  priv, tls_requires, module.check_mode, reuse_existing_password)
+                                  priv, attributes, tls_requires, reuse_existing_password, module)
                 changed = result['changed']
                 password_changed = result['password_changed']
+                final_attributes = result['attributes']
                 if changed:
                     msg = "User added"
 
@@ -546,7 +564,7 @@ def main():
         else:
             changed = False
             msg = "User doesn't exist"
-    module.exit_json(changed=changed, user=user, msg=msg, password_changed=password_changed)
+    module.exit_json(changed=changed, user=user, msg=msg, password_changed=password_changed, attributes=final_attributes)
 
 
 if __name__ == '__main__':