diff --git a/core/lib/Drupal/Core/Render/Element/Radio.php b/core/lib/Drupal/Core/Render/Element/Radio.php
index 2e8b28c..5a7996f 100644
--- a/core/lib/Drupal/Core/Render/Element/Radio.php
+++ b/core/lib/Drupal/Core/Render/Element/Radio.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\Core\Render\Element;
 
+use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Render\Element;
 
 /**
@@ -31,6 +32,7 @@ public function getInfo() {
       '#input' => TRUE,
       '#default_value' => NULL,
       '#process' => array(
+        array($class, 'processRadio'),
         array($class, 'processAjaxForm'),
       ),
       '#pre_render' => array(
@@ -43,6 +45,38 @@ public function getInfo() {
   }
 
   /**
+   * {@inheritdoc}
+   */
+  public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
+    if ($input === FALSE) {
+      // Use #default_value as the default value of a radio, except change
+      // NULL to 0, because FormBuilder::handleInputElement() would otherwise
+      // replace NULL with empty string, but an empty string is a potentially
+      // valid value for a checked radio.
+      return isset($element['#default_value']) ? $element['#default_value'] : 0;
+    }
+    else {
+      // Checked radios are submitted with a value (possibly '0' or ''):
+      // http://www.w3.org/TR/html401/interact/forms.html#successful-controls.
+      // For checked radios, browsers submit the string version of
+      // #return_value, but we return the original #return_value. For unchecked
+      // radios, browsers submit nothing at all, but
+      // FormBuilder::handleInputElement() detects this, and calls this
+      // function with $input=NULL. Returning NULL from a value callback means
+      // to use the default value, which is not what is wanted when an unchecked
+      // radio is submitted, so we use integer 0 as the value indicating an
+      // unchecked radio. Therefore, modules must not use integer 0 as a
+      // #return_value, as doing so results in the radio always being treated
+      // as unchecked. The string '0' is allowed for #return_value. The most
+      // common use-case for setting #return_value to either 0 or '0' is for the
+      // first option within a 0-indexed array of radios, and for this,
+      // \Drupal\Core\Render\Element\Checkboxes::processCheckboxes() uses the
+      // string rather than the integer.
+      return isset($input) ? $element['#return_value'] : 0;
+    }
+  }
+
+  /**
    * Prepares a #type 'radio' render element for input.html.twig.
    *
    * @param array $element
@@ -61,7 +95,8 @@ public static function preRenderRadio($element) {
     $element['#attributes']['type'] = 'radio';
     Element::setAttributes($element, array('id', 'name', '#return_value' => 'value'));
 
-    if (isset($element['#return_value']) && $element['#value'] !== FALSE && $element['#value'] == $element['#return_value']) {
+    // Unchecked radio has #value of integer 0.
+    if (!empty($element['#checked'])) {
       $element['#attributes']['checked'] = 'checked';
     }
     static::setAttributes($element, array('form-radio'));
@@ -69,4 +104,31 @@ public static function preRenderRadio($element) {
     return $element;
   }
 
+  /**
+   * Sets the #checked property of a radio element.
+   */
+  public static function processRadio(&$element, FormStateInterface $form_state, &$complete_form) {
+    $value = $element['#value'];
+    $return_value = $element['#return_value'];
+    // On form submission, the #value of an available and enabled checked
+    // radio is #return_value, and the #value of an available and enabled
+    // unchecked radio is integer 0. On not submitted forms, and for
+    // radios with #access=FALSE or #disabled=TRUE, the #value is
+    // #default_value (integer 0 if #default_value is NULL). Most of the time,
+    // a string comparison of #value and #return_value is sufficient for
+    // determining the "checked" state, but a value of TRUE always means checked
+    // (even if #return_value is 'foo'), and a value of FALSE or integer 0
+    // always means unchecked (even if #return_value is '' or '0').
+    if ($value === TRUE || $value === FALSE || $value === 0) {
+      $element['#checked'] = (bool) $value;
+    }
+    else {
+      // Compare as strings, so that 15 is not considered equal to '15foo', but
+      // 1 is considered equal to '1'. This cast does not imply that either
+      // #value or #return_value is expected to be a string.
+      $element['#checked'] = ((string) $value === (string) $return_value);
+    }
+    return $element;
+  }
+
 }
diff --git a/core/modules/system/src/Tests/Form/RadioTest.php b/core/modules/system/src/Tests/Form/RadioTest.php
new file mode 100644
index 0000000..6664fc3
--- /dev/null
+++ b/core/modules/system/src/Tests/Form/RadioTest.php
@@ -0,0 +1,89 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\system\Tests\Form\RadioTest.
+ */
+
+namespace Drupal\system\Tests\Form;
+
+use Drupal\simpletest\WebTestBase;
+
+/**
+ * Tests form API radio handling of various combinations of #default_value
+ * and #return_value.
+ *
+ * @group Form
+ */
+class RadioTest extends WebTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = array('form_test');
+
+  function testFormRadio() {
+    // Ensure that the checked state is determined and rendered correctly for
+    // tricky combinations of default and return values.
+    foreach (array(FALSE, NULL, TRUE, 0, '0', '', 1, '1', 'foobar', '1foobar') as $default_value) {
+      // Only values that can be used for array indices are supported for
+      // #return_value, with the exception of integer 0, which is not supported.
+      // @see \Drupal\Core\Render\Element\Radio::processRadio().
+      foreach (array('0', '', 1, '1', 'foobar', '1foobar') as $return_value) {
+        $form_array = \Drupal::formBuilder()->getForm('\Drupal\form_test\Form\FormTestRadioTypeJugglingForm', $default_value, $return_value);
+        $form = \Drupal::service('renderer')->renderRoot($form_array);
+        if ($default_value === TRUE) {
+          $checked = TRUE;
+        }
+        elseif ($return_value === '0') {
+          $checked = ($default_value === '0');
+        }
+        elseif ($return_value === '') {
+          $checked = ($default_value === '');
+        }
+        elseif ($return_value === 1 || $return_value === '1') {
+          $checked = ($default_value === 1 || $default_value === '1');
+        }
+        elseif ($return_value === 'foobar') {
+          $checked = ($default_value === 'foobar');
+        }
+        elseif ($return_value === '1foobar') {
+          $checked = ($default_value === '1foobar');
+        }
+        $checked_in_html = strpos($form, 'checked') !== FALSE;
+        $message = format_string('#default_value is %default_value #return_value is %return_value.', array('%default_value' => var_export($default_value, TRUE), '%return_value' => var_export($return_value, TRUE)));
+        $this->assertIdentical($checked, $checked_in_html, $message);
+      }
+    }
+
+    // Ensure that $form_state->getValues() is populated correctly for a radios
+    // group that includes a 0-indexed array of options.
+    $results = json_decode($this->drupalPostForm('form-test/radios-zero', array(), 'Save'));
+    $this->assertIdentical($results->radio_off, NULL, 'All three in radio_off are zeroes: off.');
+    $this->assertIdentical($results->radio_zero_default, 0, 'The first choice is on in radio_zero_default');
+    $this->assertIdentical($results->radio_string_zero_default, '0', 'The first choice is on in radio_string_zero_default');
+    $edit = array('radio_off' => '0');
+    $results = json_decode($this->drupalPostForm('form-test/radios-zero', $edit, 'Save'));
+    $this->assertIdentical($results->radio_off, '0', 'The first choice is on in radio_off but the rest is not');
+
+    // Ensure that each radio is rendered correctly for a radios group
+    // that includes a 0-indexed array of options.
+    $this->drupalPostForm('form-test/radios-zero/0', array(), 'Save');
+    $radios = $this->xpath('//input[@type="radio"]');
+    foreach ($radios as $radio) {
+      $checked = isset($radio['checked']);
+      $name = (string) $radio['name'];
+      $this->assertIdentical($checked, $name == 'radio_zero_default' || $name == 'radio_string_zero_default', format_string('Radio %name correctly checked', array('%name' => $name)));
+    }
+    $edit = array('radio_off' => '0');
+    $this->drupalPostForm('form-test/radios-zero/0', $edit, 'Save');
+    $radios = $this->xpath('//input[@type="radio"]');
+    foreach ($radios as $radio) {
+      $checked = isset($radio['checked']);
+      $name = (string) $radio['name'];
+      $this->assertIdentical($checked, $name == 'radio_off' || $name == 'radio_zero_default' || $name == 'radio_string_zero_default', format_string('Radio %name correctly checked', array('%name' => $name)));
+    }
+  }
+}
diff --git a/core/modules/system/tests/modules/form_test/form_test.routing.yml b/core/modules/system/tests/modules/form_test/form_test.routing.yml
index 8a200a3..3f5ad2a 100644
--- a/core/modules/system/tests/modules/form_test/form_test.routing.yml
+++ b/core/modules/system/tests/modules/form_test/form_test.routing.yml
@@ -370,6 +370,15 @@ form_test.checkboxes_zero:
   requirements:
     _access: 'TRUE'
 
+form_test.radios_zero:
+  path: '/form-test/radios-zero/{json}'
+  defaults:
+    _form: '\Drupal\form_test\Form\FormTestRadiosZeroForm'
+    _title: 'FAPI test involving radios and zero'
+    json: TRUE
+  requirements:
+    _access: 'TRUE'
+
 form_test.required:
   path: '/form-test/required-attribute'
   defaults:
diff --git a/core/modules/system/tests/modules/form_test/src/Form/FormTestRadioTypeJugglingForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestRadioTypeJugglingForm.php
new file mode 100644
index 0000000..09d59e2
--- /dev/null
+++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestRadioTypeJugglingForm.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\form_test\Form\FormTestradioTypeJugglingForm.
+ */
+
+namespace Drupal\form_test\Form;
+
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Builds a form to test return values for radios.
+ */
+class FormTestRadioTypeJugglingForm extends FormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'form_test_radio_type_juggling';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state, $default_value = NULL, $return_value = NULL) {
+    $form['radio'] = array(
+      '#title' => t('Radio'),
+      '#type' => 'radio',
+      '#return_value' => $return_value,
+      '#default_value' => $default_value,
+    );
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+  }
+
+}
diff --git a/core/modules/system/tests/modules/form_test/src/Form/FormTestRadiosZeroForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestRadiosZeroForm.php
new file mode 100644
index 0000000..2f8073c
--- /dev/null
+++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestRadiosZeroForm.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\form_test\Form\FormTestRadiosZeroForm.
+ */
+
+namespace Drupal\form_test\Form;
+
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Symfony\Component\HttpFoundation\JsonResponse;
+
+/**
+ * Tests radios zero.
+ */
+class FormTestRadiosZeroForm extends FormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'form_test_radios_zero';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state, $json = TRUE) {
+    $form_state->set('json', $json);
+    $form['radio_off'] = array(
+      '#title' => t('Radio off'),
+      '#type' => 'radios',
+      '#options' => array('foo', 'bar', 'baz'),
+    );
+    $form['radio_zero_default'] = array(
+      '#title' => t('Zero default'),
+      '#type' => 'radios',
+      '#options' => array('foo', 'bar', 'baz'),
+      '#default_value' => 0,
+    );
+    $form['radio_string_zero_default'] = array(
+      '#title' => t('Zero default (string)'),
+      '#type' => 'radios',
+      '#options' => array('foo', 'bar', 'baz'),
+      '#default_value' => '0',
+    );
+    $form['submit'] = array(
+      '#type' => 'submit',
+      '#value' => 'Save',
+    );
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    if ($form_state->has('json')) {
+      $form_state->setResponse(new JsonResponse($form_state->getValues()));
+    }
+    else {
+      $form_state->disableRedirect();
+    }
+  }
+
+}
