diff --git a/docs/docsite/rst/intro_configuration.rst b/docs/docsite/rst/intro_configuration.rst
index d44a5881e8..c0df1bd0ea 100644
--- a/docs/docsite/rst/intro_configuration.rst
+++ b/docs/docsite/rst/intro_configuration.rst
@@ -762,6 +762,27 @@ always default to the current user if this is not defined::
 
     remote_user = root
 
+
+.. _restrict_facts_namespace:
+
+restrict_facts_namespace
+========================
+
+.. versionadded:: 2.4
+
+This allows restricting facts in their own namespace (under ansible_facts) instead of pushing them into the main.
+False by default. Can also be set via the environment variable `ANSIBLE_RESTRICT_FACTS`. Using `ansible_system` as an example:
+
+When False::
+
+    - debug: var=ansible_system
+
+
+When True::
+
+    - debug: var=ansible_facts.ansible_system
+
+
 .. _retry_files_enabled:
 
 retry_files_enabled
diff --git a/examples/ansible.cfg b/examples/ansible.cfg
index 109f508002..459fc9719c 100644
--- a/examples/ansible.cfg
+++ b/examples/ansible.cfg
@@ -288,6 +288,10 @@
 # only update this setting if you know how this works, otherwise it can break module execution
 #network_group_modules=['eos', 'nxos', 'ios', 'iosxr', 'junos', 'vyos']
 
+# This keeps facts from polluting the main namespace as variables.
+# Setting to True keeps them under the ansible_facts namespace, the default is False
+#restrict_facts_namespace: True
+
 [privilege_escalation]
 #become=True
 #become_method=sudo
diff --git a/lib/ansible/constants.py b/lib/ansible/constants.py
index 1f00afcdcb..f79f9edcbb 100644
--- a/lib/ansible/constants.py
+++ b/lib/ansible/constants.py
@@ -236,6 +236,7 @@ DEFAULT_VAR_COMPRESSION_LEVEL = get_config(p, DEFAULTS, 'var_compression_level',
 DEFAULT_INTERNAL_POLL_INTERVAL = get_config(p, DEFAULTS, 'internal_poll_interval', None, 0.001, value_type='float')
 ERROR_ON_MISSING_HANDLER  = get_config(p, DEFAULTS, 'error_on_missing_handler', 'ANSIBLE_ERROR_ON_MISSING_HANDLER', True, value_type='boolean')
 SHOW_CUSTOM_STATS = get_config(p, DEFAULTS, 'show_custom_stats', 'ANSIBLE_SHOW_CUSTOM_STATS', False, value_type='boolean')
+NAMESPACE_FACTS = get_config(p, DEFAULTS, 'restrict_facts_namespace', 'ANSIBLE_RESTRICT_FACTS', False, value_type='boolean')
 
 # static includes
 DEFAULT_TASK_INCLUDES_STATIC    = get_config(p, DEFAULTS, 'task_includes_static', 'ANSIBLE_TASK_INCLUDES_STATIC', False, value_type='boolean')
diff --git a/lib/ansible/executor/task_executor.py b/lib/ansible/executor/task_executor.py
index c2c87ef5a8..64f8a6c3e1 100644
--- a/lib/ansible/executor/task_executor.py
+++ b/lib/ansible/executor/task_executor.py
@@ -557,7 +557,9 @@ class TaskExecutor:
                 return failed_when_result
 
             if 'ansible_facts' in result:
-                vars_copy.update(result['ansible_facts'])
+                if not C.NAMESPACE_FACTS:
+                    vars_copy.update(result['ansible_facts'])
+                vars_copy.update({'ansible_facts': result['ansible_facts']})
 
             # set the failed property if the result has a non-zero rc. This will be
             # overridden below if the failed_when property is set
@@ -596,7 +598,9 @@ class TaskExecutor:
             variables[self._task.register] = wrap_var(result)
 
         if 'ansible_facts' in result:
-            variables.update(result['ansible_facts'])
+            if not C.NAMESPACE_FACTS:
+                variables.update(result['ansible_facts'])
+            variables.update({'ansible_facts': result['ansible_facts']})
 
         # save the notification target in the result, if it was specified, as
         # this task may be running in a loop in which case the notification
diff --git a/lib/ansible/plugins/action/__init__.py b/lib/ansible/plugins/action/__init__.py
index 736a9cab02..95343c2e69 100644
--- a/lib/ansible/plugins/action/__init__.py
+++ b/lib/ansible/plugins/action/__init__.py
@@ -733,7 +733,7 @@ class ActionBase(with_metaclass(ABCMeta, object)):
         # actually execute
         res = self._low_level_execute_command(cmd, sudoable=sudoable, in_data=in_data)
 
-        # parse the main result, also cleans up internal keys
+        # parse the main result
         data = self._parse_returned_data(res)
 
         #NOTE: INTERNAL KEYS ONLY ACCESSIBLE HERE
diff --git a/lib/ansible/vars/__init__.py b/lib/ansible/vars/__init__.py
index 5afdfd14ee..03f435d64a 100644
--- a/lib/ansible/vars/__init__.py
+++ b/lib/ansible/vars/__init__.py
@@ -281,7 +281,11 @@ class VariableManager:
             # finally, the facts caches for this host, if it exists
             try:
                 host_facts = wrap_var(self._fact_cache.get(host.name, dict()))
-                all_vars = combine_vars(all_vars, host_facts)
+                if not C.NAMESPACE_FACTS:
+                    # allow facts to polute main namespace
+                    all_vars = combine_vars(all_vars, host_facts)
+                # always return namespaced facts
+                all_vars = combine_vars(all_vars, {'ansible_facts': host_facts})
             except KeyError:
                 pass