diff --git a/core/includes/form.inc b/core/includes/form.inc
index fefa9b3b872b575244af6c8195f6f786c83ab8ff..bacab5ef71dc3432be90d50be7024bbb8593e3ee 100644
--- a/core/includes/form.inc
+++ b/core/includes/form.inc
@@ -18,14 +18,15 @@
  * Default template: select.html.twig.
  *
  * It is possible to group options together; to do this, change the format of
- * $options to an associative array in which the keys are group labels, and the
- * values are associative arrays in the normal $options format.
+ * the #options property to an associative array in which the keys are group
+ * labels, and the values are associative arrays in the normal #options format.
  *
  * @param $variables
  *   An associative array containing:
  *   - element: An associative array containing the properties of the element.
  *     Properties used: #title, #value, #options, #description, #extra,
- *     #multiple, #required, #name, #attributes, #size.
+ *     #multiple, #required, #name, #attributes, #size, #sort_options,
+ *     #sort_start.
  */
 function template_preprocess_select(&$variables) {
   $element = $variables['element'];
@@ -37,36 +38,17 @@ function template_preprocess_select(&$variables) {
 }
 
 /**
- * Converts an options form element into a structured array for output.
+ * Converts the options in a select element into a structured array for output.
  *
  * This function calls itself recursively to obtain the values for each optgroup
  * within the list of options and when the function encounters an object with
  * an 'options' property inside $element['#options'].
  *
  * @param array $element
- *   An associative array containing the following key-value pairs:
- *   - #multiple: Optional Boolean indicating if the user may select more than
- *     one item.
- *   - #options: An associative array of options to render as HTML. Each array
- *     value can be a string, an array, or an object with an 'option' property:
- *     - A string or integer key whose value is a translated string is
- *       interpreted as a single HTML option element. Do not use placeholders
- *       that sanitize data: doing so will lead to double-escaping. Note that
- *       the key will be visible in the HTML and could be modified by malicious
- *       users, so don't put sensitive information in it.
- *     - A translated string key whose value is an array indicates a group of
- *       options. The translated string is used as the label attribute for the
- *       optgroup. Do not use placeholders to sanitize data: doing so will lead
- *       to double-escaping. The array should contain the options you wish to
- *       group and should follow the syntax of $element['#options'].
- *     - If the function encounters a string or integer key whose value is an
- *       object with an 'option' property, the key is ignored, the contents of
- *       the option property are interpreted as $element['#options'], and the
- *       resulting HTML is added to the output.
- *   - #value: Optional integer, string, or array representing which option(s)
- *     to pre-select when the list is first displayed. The integer or string
- *     must match the key of an option in the '#options' list. If '#multiple' is
- *     TRUE, this can be an array of integers or strings.
+ *   An associative array containing properties of the select element. See
+ *   \Drupal\Core\Render\Element\Select for details, but note that the
+ *   #empty_option and #empty_value properties are processed, and the
+ *   #value property is set, before reaching this function.
  * @param array|null $choices
  *   (optional) Either an associative array of options in the same format as
  *   $element['#options'] above, or NULL. This parameter is only used internally
@@ -90,7 +72,17 @@ function form_select_options($element, $choices = NULL) {
       return [];
     }
     $choices = $element['#options'];
+    $sort_options = isset($element['#sort_options']) && $element['#sort_options'];
+    $sort_start = $element['#sort_start'] ?? 0;
   }
+  else {
+    // We are within an option group.
+    $sort_options = isset($choices['#sort_options']) && $choices['#sort_options'];
+    $sort_start = $choices['#sort_start'] ?? 0;
+    unset($choices['#sort_options']);
+    unset($choices['#sort_start']);
+  }
+
   // array_key_exists() accommodates the rare event where $element['#value'] is NULL.
   // isset() fails in this situation.
   $value_valid = isset($element['#value']) || array_key_exists('#value', $element);
@@ -125,6 +117,14 @@ function form_select_options($element, $choices = NULL) {
       $options[] = $option;
     }
   }
+  if ($sort_options) {
+    $unsorted = array_slice($options, 0, $sort_start);
+    $sorted = array_slice($options, $sort_start);
+    uasort($sorted, function ($a, $b) {
+      return strcmp((string) $a['label'], (string) $b['label']);
+    });
+    $options = array_merge($unsorted, $sorted);
+  }
   return $options;
 }
 
diff --git a/core/lib/Drupal/Core/Render/Element/Select.php b/core/lib/Drupal/Core/Render/Element/Select.php
index 300c244fc123113918ec23357df9a9c116c1b3d4..fa2e7377c184f5c1357b56b90a5b281d5879029e 100644
--- a/core/lib/Drupal/Core/Render/Element/Select.php
+++ b/core/lib/Drupal/Core/Render/Element/Select.php
@@ -9,11 +9,32 @@
  * Provides a form element for a drop-down menu or scrolling selection box.
  *
  * Properties:
- * - #options: An associative array, where the keys are the values for each
- *   option, and the values are the option labels to be shown in the drop-down
- *   list. If a value is an array, it will be rendered similarly, but as an
- *   optgroup. The key of the sub-array will be used as the label for the
- *   optgroup. Nesting optgroups is not allowed.
+ * - #options: An associative array of options for the select. Do not use
+ *   placeholders that sanitize data in any labels, as doing so will lead to
+ *   double-escaping. Each array value can be:
+ *   - A single translated string representing an HTML option element, where
+ *     the outer array key is the option value and the translated string array
+ *     value is the option label. The option value will be visible in the HTML
+ *     and can be modified by malicious users, so it should not contain
+ *     sensitive information and should be treated as possibly malicious data in
+ *     processing.
+ *   - An array representing an HTML optgroup element. The outer array key
+ *     should be a translated string, and is used as the label for the group.
+ *     The inner array contains the options for the group (with the keys as
+ *     option values, and translated string values as option labels). Nesting
+ *     option groups is not supported.
+ *   - An object with an 'option' property. In this case, the outer array key
+ *     is ignored, and the contents of the 'option' property are interpreted as
+ *     an array of options to be merged with any other regular options and
+ *     option groups found in the outer array.
+ * - #sort_options: (optional) If set to TRUE (default is FALSE), sort the
+ *   options by their labels, after rendering and translation is complete.
+ *   Can be set within an option group to sort that group.
+ * - #sort_start: (optional) Option index to start sorting at, where 0 is the
+ *   first option. Can be used within an option group. If an empty option is
+ *   being added automatically (see #empty_option and #empty_value properties),
+ *   this defaults to 1 to keep the empty option at the top of the list.
+ *   Otherwise, it defaults to 0.
  * - #empty_option: (optional) The label to show for the first default option.
  *   By default, the label is automatically set to "- Select -" for a required
  *   field and "- None -" for an optional field.
@@ -68,6 +89,8 @@ public function getInfo() {
     return [
       '#input' => TRUE,
       '#multiple' => FALSE,
+      '#sort_options' => FALSE,
+      '#sort_start' => NULL,
       '#process' => [
         [$class, 'processSelect'],
         [$class, 'processAjaxForm'],
@@ -129,6 +152,9 @@ public static function processSelect(&$element, FormStateInterface $form_state,
         $element['#options'] = $empty_option + $element['#options'];
       }
     }
+    // Provide the correct default value for #sort_start.
+    $element['#sort_start'] = $element['#sort_start'] ??
+      (isset($element['#empty_value']) ? 1 : 0);
     return $element;
   }
 
diff --git a/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php
index d1b14d8d0e8789d67ef588ef816b5f46dcaa2447..becdef16180afe70975934297d1f1291dd0ac6e0 100644
--- a/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php
+++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php
@@ -4,6 +4,7 @@
 
 use Drupal\Core\Form\FormBase;
 use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Symfony\Component\HttpFoundation\JsonResponse;
 
 /**
@@ -125,10 +126,91 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       ],
     ];
 
+    // Add a select that should have its options left alone.
+    $form['unsorted'] = [
+      '#type' => 'select',
+      '#options' => $this->makeSortableOptions('uso'),
+    ];
+
+    // Add a select to test sorting at the top level, and with some of the
+    // option groups sorted, some left alone, and at least one with #sort_start
+    // set to a non-default value.
+    $sortable_options = $this->makeSortableOptions('sso');
+    $sortable_options['sso_zzgroup']['#sort_options'] = TRUE;
+    $sortable_options['sso_xxgroup']['#sort_options'] = TRUE;
+    $sortable_options['sso_xxgroup']['#sort_start'] = 1;
+    // Do not use a sort start on this one.
+    $form['sorted'] = [
+      '#type' => 'select',
+      '#options' => $sortable_options,
+      '#sort_options' => TRUE,
+    ];
+
+    // Add a select to test sorting with a -NONE- option included,
+    // and #sort_start set.
+    $sortable_none_options = $this->makeSortableOptions('sno');
+    $sortable_none_options['sno_zzgroup']['#sort_options'] = TRUE;
+    $form['sorted_none'] = [
+      '#type' => 'select',
+      '#options' => $sortable_none_options,
+      '#sort_options' => TRUE,
+      '#sort_start' => 4,
+      '#empty_value' => 'sno_empty',
+    ];
+
+    // Add a select to test sorting with a -NONE- option included,
+    // and #sort_start not set.
+    $sortable_none_nostart_options = $this->makeSortableOptions('snn');
+    $sortable_none_nostart_options['snn_zzgroup']['#sort_options'] = TRUE;
+    $form['sorted_none_nostart'] = [
+      '#type' => 'select',
+      '#options' => $sortable_none_nostart_options,
+      '#sort_options' => TRUE,
+      '#empty_value' => 'snn_empty',
+    ];
+
     $form['submit'] = ['#type' => 'submit', '#value' => 'Submit'];
     return $form;
   }
 
+  /**
+   * Makes and returns a set of options to test sorting on.
+   *
+   * @param string $prefix
+   *   Prefix for the keys of the options.
+   *
+   * @return array
+   *   Options array, including option groups, for testing.
+   */
+  protected function makeSortableOptions($prefix) {
+    return [
+      // Don't use $this->t() here, to avoid adding strings to
+      // localize.drupal.org. Do use TranslatableMarkup in places, to test
+      // that labels are cast to strings before sorting.
+      $prefix . '_first_element' => new TranslatableMarkup('first element'),
+      $prefix . '_second' => new TranslatableMarkup('second element'),
+      $prefix . '_zzgroup' => [
+        $prefix . '_gc' => new TranslatableMarkup('group c'),
+        $prefix . '_ga' => new TranslatableMarkup('group a'),
+        $prefix . '_gb' => 'group b',
+      ],
+      $prefix . '_yygroup' => [
+        $prefix . '_ge' => new TranslatableMarkup('group e'),
+        $prefix . '_gd' => new TranslatableMarkup('group d'),
+        $prefix . '_gf' => new TranslatableMarkup('group f'),
+      ],
+      $prefix . '_xxgroup' => [
+        $prefix . '_gz' => new TranslatableMarkup('group z'),
+        $prefix . '_gi' => new TranslatableMarkup('group i'),
+        $prefix . '_gh' => new TranslatableMarkup('group h'),
+      ],
+      $prefix . '_d' => 'd',
+      $prefix . '_c' => new TranslatableMarkup('main c'),
+      $prefix . '_b' => new TranslatableMarkup('main b'),
+      $prefix . '_a' => 'a',
+    ];
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/core/modules/system/tests/src/Functional/Form/FormTest.php b/core/modules/system/tests/src/Functional/Form/FormTest.php
index bd45bbcf788bdf1ba83f653d41943ae85ae0472b..0551c88322d3b9edae967acb4352dac36069710d 100644
--- a/core/modules/system/tests/src/Functional/Form/FormTest.php
+++ b/core/modules/system/tests/src/Functional/Form/FormTest.php
@@ -12,6 +12,7 @@
 use Drupal\Tests\BrowserTestBase;
 use Drupal\user\RoleInterface;
 use Drupal\filter\Entity\FilterFormat;
+use Behat\Mink\Element\NodeElement;
 
 /**
  * Tests various form element validation mechanisms.
@@ -463,6 +464,126 @@ public function testEmptySelect() {
     $this->assertNoFieldByXPath("//select[1]/option", NULL, 'No option element found.');
   }
 
+  /**
+   * Tests sorting and not sorting of options in a select element.
+   */
+  public function testSelectSorting() {
+    $this->drupalGet('form-test/select');
+
+    // Verify the order of the select options.
+    $this->validateSelectSorting('unsorted', [
+      'uso_first_element',
+      'uso_second',
+      'uso_zzgroup',
+      'uso_gc',
+      'uso_ga',
+      'uso_gb',
+      'uso_yygroup',
+      'uso_ge',
+      'uso_gd',
+      'uso_gf',
+      'uso_xxgroup',
+      'uso_gz',
+      'uso_gi',
+      'uso_gh',
+      'uso_d',
+      'uso_c',
+      'uso_b',
+      'uso_a',
+    ]);
+
+    $this->validateSelectSorting('sorted', [
+      'sso_a',
+      'sso_d',
+      'sso_first_element',
+      'sso_b',
+      'sso_c',
+      'sso_second',
+      'sso_xxgroup',
+      'sso_gz',
+      'sso_gh',
+      'sso_gi',
+      'sso_yygroup',
+      'sso_ge',
+      'sso_gd',
+      'sso_gf',
+      'sso_zzgroup',
+      'sso_ga',
+      'sso_gb',
+      'sso_gc',
+    ]);
+
+    $this->validateSelectSorting('sorted_none', [
+      'sno_empty',
+      'sno_first_element',
+      'sno_second',
+      'sno_zzgroup',
+      'sno_ga',
+      'sno_gb',
+      'sno_gc',
+      'sno_a',
+      'sno_d',
+      'sno_b',
+      'sno_c',
+      'sno_xxgroup',
+      'sno_gz',
+      'sno_gi',
+      'sno_gh',
+      'sno_yygroup',
+      'sno_ge',
+      'sno_gd',
+      'sno_gf',
+    ]);
+
+    $this->validateSelectSorting('sorted_none_nostart', [
+      'snn_empty',
+      'snn_a',
+      'snn_d',
+      'snn_first_element',
+      'snn_b',
+      'snn_c',
+      'snn_second',
+      'snn_xxgroup',
+      'snn_gz',
+      'snn_gi',
+      'snn_gh',
+      'snn_yygroup',
+      'snn_ge',
+      'snn_gd',
+      'snn_gf',
+      'snn_zzgroup',
+      'snn_ga',
+      'snn_gb',
+      'snn_gc',
+    ]);
+
+    // Verify that #sort_order and #sort_start are not in the page.
+    $this->assertSession()->responseNotContains('#sort_order');
+    $this->assertSession()->responseNotContains('#sort_start');
+  }
+
+  /**
+   * Validates that the options are in the right order in a select.
+   *
+   * @param string $select
+   *   Name of the select to verify.
+   * @param string[] $order
+   *   Expected order of its options.
+   */
+  protected function validateSelectSorting($select, array $order) {
+    $option_map_function = function (NodeElement $node) {
+      return ($node->getTagName() === 'optgroup') ?
+        $node->getAttribute('label') : $node->getValue();
+    };
+    $option_nodes = $this->getSession()
+      ->getPage()
+      ->findField($select)
+      ->findAll('css', 'option, optgroup');
+
+    $options = array_map($option_map_function, $option_nodes);
+    $this->assertIdentical($order, $options);
+  }
+
   /**
    * Tests validation of #type 'number' and 'range' elements.
    */