diff --git a/core/lib/Drupal/Core/Url.php b/core/lib/Drupal/Core/Url.php
index b2e3062942f82f26959177df2ad0103d15843a25..859cf07ede82ae67be8be7450265f35928465940 100644
--- a/core/lib/Drupal/Core/Url.php
+++ b/core/lib/Drupal/Core/Url.php
@@ -250,13 +250,18 @@ public static function fromUri($uri, $options = []) {
       throw new \InvalidArgumentException(String::format('The URI "@uri" is invalid. You must use a valid URI scheme. Use base: for items like a static file that needs the base path. Use user-path: for user input without a scheme. Use entity: for referencing the canonical route of a content entity. Use route: for directly representing a route name and parameters.', ['@uri' => $uri]));
     }
     $uri_parts += ['path' => ''];
+    // Discard empty fragment in $options for consistency with parse_url().
+    if (isset($options['fragment']) && strlen($options['fragment']) == 0) {
+      unset($options['fragment']);
+    }
     // Extract query parameters and fragment and merge them into $uri_options,
     // but preserve the original $options for the fallback case.
     $uri_options = $options;
-    if (!empty($uri_parts['fragment'])) {
+    if (isset($uri_parts['fragment'])) {
       $uri_options += ['fragment' => $uri_parts['fragment']];
+      unset($uri_parts['fragment']);
     }
-    unset($uri_parts['fragment']);
+
     if (!empty($uri_parts['query'])) {
       $uri_query = [];
       parse_str($uri_parts['query'], $uri_query);
@@ -302,7 +307,7 @@ public static function fromUri($uri, $options = []) {
    * @throws \InvalidArgumentException
    *   Thrown if the entity URI is invalid.
    */
-  protected static function fromEntityUri(array $uri_parts, $options, $uri) {
+  protected static function fromEntityUri(array $uri_parts, array $options, $uri) {
     list($entity_type_id, $entity_id) = explode('/', $uri_parts['path'], 2);
     if ($uri_parts['scheme'] != 'entity' || $entity_id === '') {
       throw new \InvalidArgumentException(String::format('The entity URI "@uri" is invalid. You must specify the entity id in the URL. e.g., entity:node/1 for loading the canonical path to node entity with id 1.', ['@uri' => $uri]));
@@ -410,12 +415,12 @@ protected function setUnrouted() {
   }
 
   /**
-   * Return a URI string that represents tha data in the Url object.
+   * Generates a URI string that represents tha data in the Url object.
    *
    * The URI will typically have the scheme of route: even if the object was
-   * constructed using an entity: or user-path: scheme.  A user-path: URI
-   * that does not match a Drupal route with be returned here with the base:
-   * scheme, and external URLs will be returned in their original form.
+   * constructed using an entity: or user-path: scheme. A user-path: URI that
+   * does not match a Drupal route with be returned here with the base: scheme,
+   * and external URLs will be returned in their original form.
    *
    * @return string
    *   A URI representation of the Url object data.
@@ -431,7 +436,7 @@ public function toUriString() {
       $uri = $this->uri;
     }
     $query = !empty($this->options['query']) ? ('?' . UrlHelper::buildQuery($this->options['query'])) : '';
-    $fragment = !empty($this->options['fragment']) ? '#' . $this->options['fragment'] : '';
+    $fragment = isset($this->options['fragment']) && strlen($this->options['fragment']) ? '#' . $this->options['fragment'] : '';
     return $uri . $query . $fragment;
   }
 
@@ -580,7 +585,7 @@ public function setOption($name, $value) {
   }
 
   /**
-   * Returns the URI of the URL.
+   * Returns the URI value for this Url object.
    *
    * Only to be used if self::$unrouted is TRUE.
    *
@@ -599,7 +604,7 @@ public function getUri() {
   }
 
   /**
-   * Sets the absolute value for this Url.
+   * Sets the value of the absolute option for this Url.
    *
    * @param bool $absolute
    *   (optional) Whether to make this Url absolute or not. Defaults to TRUE.
@@ -612,7 +617,19 @@ public function setAbsolute($absolute = TRUE) {
   }
 
   /**
-   * Generates the URI for this Url object.
+   * Generates the string URL representation for this Url object.
+   *
+   * For an external URL, the string will contain the input plus any query
+   * string or fragment specified by the options array.
+   *
+   * If this Url object was constructed from a Drupal route or from an internal
+   * URI (URIs using the user-path:, base:, or entity: schemes), the returned
+   * string will either be a relative URL like /node/1 or an absolute URL like
+   * http://example.com/node/1 depending on the options array, plus any
+   * specified query string or fragment.
+   *
+   * @return string
+   *   A string URL.
    */
   public function toString() {
     if ($this->unrouted) {
diff --git a/core/tests/Drupal/Tests/Core/UrlTest.php b/core/tests/Drupal/Tests/Core/UrlTest.php
index b26feb8dbd73102f2eb1fbd135e9a1cb8327c3d1..3f0ec3ef0c3ea1bbc4dc5c04f275ecf1402297f1 100644
--- a/core/tests/Drupal/Tests/Core/UrlTest.php
+++ b/core/tests/Drupal/Tests/Core/UrlTest.php
@@ -491,6 +491,33 @@ public function providerTestEntityUris() {
         NULL,
         NULL,
       ],
+      [
+        // Ensure a fragment of #0 is handled correctly.
+        'entity:test_entity/1#0',
+        [],
+        'entity.test_entity.canonical',
+        ['test_entity' => '1'],
+        NULL,
+        '0',
+      ],
+      // Ensure an empty fragment of # is in options discarded as expected.
+      [
+        'entity:test_entity/1',
+        ['fragment' => ''],
+        'entity.test_entity.canonical',
+        ['test_entity' => '1'],
+        NULL,
+        NULL,
+      ],
+      // Ensure an empty fragment of # in the URI is discarded as expected.
+      [
+        'entity:test_entity/1#',
+        [],
+        'entity.test_entity.canonical',
+        ['test_entity' => '1'],
+        NULL,
+        NULL,
+      ],
       [
         'entity:test_entity/2?page=1&foo=bar#bottom',
         [], 'entity.test_entity.canonical',
@@ -613,6 +640,12 @@ public function providerTestToUriStringForRoute() {
       ['route:entity.test_entity.canonical;test_entity=1', [], 'route:entity.test_entity.canonical;test_entity=1'],
       ['route:entity.test_entity.canonical;test_entity=1', ['fragment' => 'top', 'query' => ['page' => '2']], 'route:entity.test_entity.canonical;test_entity=1?page=2#top'],
       ['route:entity.test_entity.canonical;test_entity=1?page=2#top', [], 'route:entity.test_entity.canonical;test_entity=1?page=2#top'],
+      // Check that an empty fragment is discarded.
+      ['route:entity.test_entity.canonical;test_entity=1?page=2#', [], 'route:entity.test_entity.canonical;test_entity=1?page=2'],
+      // Check that an empty fragment is discarded.
+      ['route:entity.test_entity.canonical;test_entity=1?page=2', ['fragment' => ''], 'route:entity.test_entity.canonical;test_entity=1?page=2'],
+      // Check that a fragment of #0 is preserved.
+      ['route:entity.test_entity.canonical;test_entity=1?page=2#0', [], 'route:entity.test_entity.canonical;test_entity=1?page=2#0'],
     ];
   }