Repository: astropy/astroquery

Issue:
Re-enable rectangular search for SDSS query_region
#2477 Removed rectangular search from `query_region` in `SDSS` and converted it to a cone search. However, a drawback was not significantly discussed:

- The rectangular search can use a much larger "radius" i.e. field size. The limit for a cone search is 3 arcmin while the limit for a rectangular search can be as large as 12 arcmin (or more).

With the sparse fields we usually query for extragalactic transients, this does not usually get close to row-size limits. 

The changes in #2477 were made without Depreciation warnings as well, which I understand with the pre-release model.. however it would have been nice to at least preserve the old endpoint in  function such as `query_rectangle_region` or in a parameter such as `rectangular: bool = False` (where setting it to True uses the old `query_region`) so that backwards compatibility can be maintained.

Anyway this is a Feature Request to add this back in, as the old version still works (can be tested by pinning `astroquery` to e.g., `v0.4.6`, but making the change to the endpoint being HTTPS, a recent fix from #2654):

```Python
from astroquery import sdss
sdss.conf.skyserver_baseurl = sdss.conf.skyserver_baseurl.replace("http://","https://")
working_sdss_table = sdss.SDSS.query_region(<pars>,radius=<large radius multiple times over the MAX_CROSSID_RADIUS>) 
```

In fact in SDSS rectangular search there is only a suggestion, not a hard limit, on "radius" of 0.2 degrees, which seems to be not enforced by radius but by rows returned. See e.g.,: https://skyserver.sdss.org/dr17/SearchTools/rect

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index c44aa847..2413c14d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -217,8 +217,8 @@ gaia
 sdss
 ^^^^
 
-- ``query_region()`` now does a cone search around the specified
-  coordinates. [#2477]
+- ``query_region()`` can perform cone search or a rectangular
+  search around the specified coordinates. [#2477, #2663]
 
 - The default data release has been changed to DR17. [#2478]
 
diff --git a/astroquery/sdss/core.py b/astroquery/sdss/core.py
index 9a4f761d..2f548042 100644
--- a/astroquery/sdss/core.py
+++ b/astroquery/sdss/core.py
@@ -66,6 +66,8 @@ class SDSSClass(BaseQuery):
 
         This query returns the nearest `primary object`_.
 
+        Note that there is a server-side limit of 3 arcmin on ``radius``.
+
         .. _`primary object`: https://www.sdss.org/dr17/help/glossary/#surveyprimary
 
         Parameters
@@ -124,6 +126,14 @@ class SDSSClass(BaseQuery):
         cache : bool, optional
             If ``True`` use the request caching mechanism.
 
+        Raises
+        ------
+        TypeError
+            If the ``radius`` keyword could not be parsed as an angle.
+        ValueError
+            If the ``radius`` exceeds 3 arcmin, or if the sizes of
+            ``coordinates`` and ``obj_names`` do not match.
+
         Returns
         -------
         result : `~astropy.table.Table`
@@ -193,16 +203,31 @@ class SDSSClass(BaseQuery):
                                  timeout=timeout, cache=cache)
         return response
 
-    def query_region_async(self, coordinates, *, radius=2. * u.arcsec, timeout=TIMEOUT,
+    def query_region_async(self, coordinates, *, radius=None,
+                           width=None, height=None, timeout=TIMEOUT,
                            fields=None, photoobj_fields=None, specobj_fields=None, obj_names=None,
                            spectro=False, field_help=False, get_query_payload=False,
                            data_release=conf.default_release, cache=True):
-        """
-        Used to query a circular region (a "cone search") around given coordinates.
-
-        This function is equivalent to the object cross-ID (`query_crossid`),
-        with slightly different parameters. It returns all objects within the
-        search radius; this could potentially include duplicate observations
+        r"""
+        Used to query a region around given coordinates. Either ``radius`` or
+        ``width`` must be specified.
+
+        When called with keyword ``radius``, a radial or "cone" search is
+        performed, centered on each of the given coordinates. In this mode, internally,
+        this function is equivalent to the object cross-ID (`query_crossid`),
+        with slightly different parameters.  Note that in this mode there is a server-side
+        limit of 3 arcmin on ``radius``.
+
+        When called with keyword ``width``, and optionally a different ``height``,
+        a rectangular search is performed, centered on each of the given
+        coordinates. In this mode, internally, this function is equivalent to
+        a general SQL query (`query_sql`). The shape of the rectangle is
+        not corrected for declination (*i.e.* no :math:`\cos \delta` correction);
+        conceptually, this means that the rectangle will become increasingly
+        trapezoidal-shaped at high declination.
+
+        In both radial and rectangular modes, this function returns all objects
+        within the search area; this could potentially include duplicate observations
         of the same object.
 
         Parameters
@@ -221,8 +246,17 @@ class SDSSClass(BaseQuery):
         radius : str or `~astropy.units.Quantity` object, optional
             The string must be parsable by `~astropy.coordinates.Angle`. The
             appropriate `~astropy.units.Quantity` object from
-            `astropy.units` may also be used. Defaults to 2 arcsec.
+            `astropy.units` may also be used.
             The maximum allowed value is 3 arcmin.
+        width : str or `~astropy.units.Quantity` object, optional
+            The string must be parsable by `~astropy.coordinates.Angle`. The
+            appropriate `~astropy.units.Quantity` object from
+            `astropy.units` may also be used.
+        height : str or `~astropy.units.Quantity` object, optional
+            The string must be parsable by `~astropy.coordinates.Angle`. The
+            appropriate `~astropy.units.Quantity` object from
+            `astropy.units` may also be used. If not specified, it will be
+            set to the same value as ``width``.
         timeout : float, optional
             Time limit (in seconds) for establishing successful connection with
             remote server.  Defaults to `SDSSClass.TIMEOUT`.
@@ -256,6 +290,16 @@ class SDSSClass(BaseQuery):
         cache : bool, optional
             If ``True`` use the request caching mechanism.
 
+        Raises
+        ------
+        TypeError
+            If the ``radius``, ``width`` or ``height`` keywords could not be parsed as an angle.
+        ValueError
+            If both ``radius`` and ``width`` are set (or neither),
+            or if the ``radius`` exceeds 3 arcmin,
+            or if the sizes of ``coordinates`` and ``obj_names`` do not match,
+            or if the units of ``width`` or ``height`` could not be parsed.
+
         Examples
         --------
         >>> from astroquery.sdss import SDSS
@@ -277,16 +321,69 @@ class SDSSClass(BaseQuery):
             The result of the query as a `~astropy.table.Table` object.
 
         """
-        request_payload, files = self.query_crossid_async(coordinates=coordinates,
-                                                          radius=radius, fields=fields,
-                                                          photoobj_fields=photoobj_fields,
-                                                          specobj_fields=specobj_fields,
-                                                          obj_names=obj_names,
-                                                          spectro=spectro,
-                                                          region=True,
-                                                          field_help=field_help,
-                                                          get_query_payload=True,
-                                                          data_release=data_release)
+        # Allow field_help requests to pass without requiring a radius or width.
+        if field_help and radius is None and width is None:
+            radius = 2.0 * u.arcsec
+
+        if radius is None and width is None:
+            raise ValueError("Either radius or width must be specified!")
+        if radius is not None and width is not None:
+            raise ValueError("Either radius or width must be specified, not both!")
+
+        if radius is not None:
+            request_payload, files = self.query_crossid_async(coordinates=coordinates,
+                                                              radius=radius, fields=fields,
+                                                              photoobj_fields=photoobj_fields,
+                                                              specobj_fields=specobj_fields,
+                                                              obj_names=obj_names,
+                                                              spectro=spectro,
+                                                              region=True,
+                                                              field_help=field_help,
+                                                              get_query_payload=True,
+                                                              data_release=data_release)
+
+        if width is not None:
+            width = u.Quantity(width, u.degree).value
+            if height is None:
+                height = width
+            else:
+                height = u.Quantity(height, u.degree).value
+
+            dummy_payload = self._args_to_payload(coordinates=coordinates,
+                                                  fields=fields,
+                                                  spectro=spectro, region=True,
+                                                  photoobj_fields=photoobj_fields,
+                                                  specobj_fields=specobj_fields, field_help=field_help,
+                                                  data_release=data_release)
+
+            sql_query = dummy_payload['uquery'].replace('#upload u JOIN #x x ON x.up_id = u.up_id JOIN ', '')
+
+            if 'SpecObjAll' in dummy_payload['uquery']:
+                sql_query = sql_query.replace('ON p.objID = x.objID ', '').replace(' ORDER BY x.up_id', '')
+            else:
+                sql_query = sql_query.replace(' ON p.objID = x.objID ORDER BY x.up_id', '')
+
+            if (not isinstance(coordinates, list) and not isinstance(coordinates, Column)
+                    and not (isinstance(coordinates, commons.CoordClasses) and not coordinates.isscalar)):
+                coordinates = [coordinates]
+
+            rectangles = list()
+            for target in coordinates:
+                # Query for a rectangle
+                target = commons.parse_coordinates(target).transform_to('fk5')
+                rectangles.append(self._rectangle_sql(target.ra.degree, target.dec.degree, width, height=height))
+
+            rect = ' OR '.join(rectangles)
+
+            # self._args_to_payload only returns a WHERE if e.g. plate, mjd, fiber
+            # are set, which will not happen in this function.
+            sql_query += f' WHERE ({rect})'
+
+            return self.query_sql_async(sql_query, timeout=timeout,
+                                        data_release=data_release,
+                                        cache=cache,
+                                        field_help=field_help,
+                                        get_query_payload=get_query_payload)
 
         if get_query_payload or field_help:
             return request_payload
@@ -510,7 +607,7 @@ class SDSSClass(BaseQuery):
         if data_release > 11:
             request_payload['searchtool'] = 'SQL'
 
-        if kwargs.get('get_query_payload'):
+        if kwargs.get('get_query_payload') or kwargs.get('field_help'):
             return request_payload
 
         url = self._get_query_url(data_release)
@@ -1163,5 +1260,58 @@ class SDSSClass(BaseQuery):
         self._last_url = url
         return url
 
+    def _rectangle_sql(self, ra, dec, width, height=None, cosdec=False):
+        """
+        Generate SQL for a rectangular query centered on ``ra``, ``dec``.
+
+        This assumes that RA is defined on the range ``[0, 360)``, and Dec on
+        ``[-90, 90]``.
+
+        Parameters
+        ----------
+        ra : float
+            Right Ascension in degrees.
+        dec : float
+            Declination in degrees.
+        width : float
+            Width of rectangle in degrees.
+        height : float, optional
+            Height of rectangle in degrees. If not specified, ``width`` is used.
+        cosdec : bool, optional
+            If ``True`` apply ``cos(dec)`` correction to the rectangle.
+            Otherwise, rectangles become increasingly triangle-like
+            near the poles.
+
+        Returns
+        -------
+        :class:`str`
+            A string defining the rectangle in SQL notation.
+        """
+        if height is None:
+            height = width
+        dr = width/2.0
+        dd = height/2.0
+        d0 = dec - dd
+        if d0 < -90:
+            d0 = -90.0
+        d1 = dec + dd
+        if d1 > 90.0:
+            d1 = 90.0
+        ra_wrap = False
+        r0 = ra - dr
+        if r0 < 0:
+            ra_wrap = True
+            r0 += 360.0
+        r1 = ra + dr
+        if r1 > 360.0:
+            ra_wrap = True
+            r1 -= 360.0
+        # BETWEEN is inclusive, so it is equivalent to the <=, >= operators.
+        if ra_wrap:
+            sql = f"(((p.ra >= {r0:g}) OR (p.ra <= {r1:g}))"
+        else:
+            sql = f"((p.ra BETWEEN {r0:g} AND {r1:g})"
+        return sql + f" AND (p.dec BETWEEN {d0:g} AND {d1:g}))"
+
 
 SDSS = SDSSClass()
diff --git a/docs/sdss/sdss.rst b/docs/sdss/sdss.rst
index bffbb553..5d4d2179 100644
--- a/docs/sdss/sdss.rst
+++ b/docs/sdss/sdss.rst
@@ -17,18 +17,18 @@ images, are also included in DR17.  Users may select alternate DR's.
 Getting started
 ===============
 
-This example shows how to perform an object cross-ID with SDSS. We'll start
-with the position of a source found in another survey, and search within a 5
-arcsecond radius for optical counterparts in SDSS. Note use of the keyword
-argument spectro, which requires matches to have spectroscopy, not just
-photometry:
+This example shows how to perform an individual object cross-ID with SDSS.
+We'll start with the position of a source found in another survey, and search
+within a 5 arcsecond radius (a "cone search") for optical counterparts in SDSS.
+Note use of the keyword argument ``spectro``, which requires matches to have
+spectroscopy, not just photometry:
 
 .. doctest-remote-data::
 
     >>> from astroquery.sdss import SDSS
     >>> from astropy import coordinates as coords
     >>> pos = coords.SkyCoord('0h8m05.63s +14d50m23.3s', frame='icrs')
-    >>> xid = SDSS.query_region(pos, spectro=True)
+    >>> xid = SDSS.query_region(pos, radius='5 arcsec', spectro=True)
     >>> print(xid)
            ra              dec        ...     specobjid      run2d
     ---------------- ---------------- ... ------------------ -----
@@ -36,6 +36,38 @@ photometry:
 
 The result is an astropy.Table.
 
+Searching regions and multiple objects
+======================================
+
+You can use `~astroquery.sdss.SDSSClass.query_region` to search multiple
+locations; the input coordinates can be a single `astropy.coordinates` object
+or a `list` or `~astropy.table.Column` of coordinates.
+However, it is important to specify exactly what kind of search is
+desired.  When `~astroquery.sdss.SDSSClass.query_region` is invoked with the
+``radius`` keyword, a circle around each point is searched. This is also
+called a "cone search".  When invoked in this mode,
+`~astroquery.sdss.SDSSClass.query_region` is equivalent to
+`~astroquery.sdss.SDSSClass.query_crossid`. Because of this equivalence, there
+is a strict limit of 3 arcmin on the value of ``radius`` which is imposed
+by the SDSS servers.
+
+`~astroquery.sdss.SDSSClass.query_region` can also be used to search a
+rectangular region centered on a coordinate or each coordinate in a list.
+This mode is invoked with the ``width`` keyword, which is the width in
+right ascension. Optionally, the ``height`` keyword can be used to specify
+a different range of declination.  With these parameters,
+`~astroquery.sdss.SDSSClass.query_region` constructs a rectangle in RA, dec
+that does *not* correct for the geometry at high declination, also known as
+the :math:`\cos \delta` correction.  At high declination, these rectangles
+would appear much more like trapezoids. However, this is the more intuitive
+interpretation of "this range of RA, that range of Dec" that many people use.
+Finally, though, the constructed rectangles *do* account for RA wrap-around,
+so an appropriate region of the celestial sphere is searched, even if the
+central coordinate is very close to RA = 0.
+
+Finally note that either ``radius`` or ``width`` must be specified.
+Specifying neither or both will raise an exception.
+
 Downloading data
 ================
 If we'd like to download spectra and/or images for our match, we have all


Test patch:
diff --git a/astroquery/sdss/tests/test_sdss.py b/astroquery/sdss/tests/test_sdss.py
index 33e28588..10558757 100644
--- a/astroquery/sdss/tests/test_sdss.py
+++ b/astroquery/sdss/tests/test_sdss.py
@@ -123,26 +123,26 @@ dr_list = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17)
 # interfaces are supported for DR11."
 def url_tester(data_release):
     if data_release < 10:
-        baseurl = 'https://skyserver.sdss.org/dr{}/en/tools/search/x_sql.asp'
+        baseurl = f'https://skyserver.sdss.org/dr{data_release}/en/tools/search/x_sql.asp'
     if data_release == 10:
-        baseurl = 'https://skyserver.sdss.org/dr{}/en/tools/search/x_sql.aspx'
+        baseurl = f'https://skyserver.sdss.org/dr{data_release}/en/tools/search/x_sql.aspx'
     if data_release == 11:
         return
     if data_release >= 12:
-        baseurl = 'https://skyserver.sdss.org/dr{}/en/tools/search/x_results.aspx'
-    assert sdss.SDSS._last_url == baseurl.format(data_release)
+        baseurl = f'https://skyserver.sdss.org/dr{data_release}/en/tools/search/x_results.aspx'
+    assert sdss.SDSS._last_url == baseurl
 
 
 def url_tester_crossid(data_release):
     if data_release < 10:
-        baseurl = 'https://skyserver.sdss.org/dr{}/en/tools/crossid/x_crossid.asp'
+        baseurl = f'https://skyserver.sdss.org/dr{data_release}/en/tools/crossid/x_crossid.asp'
     if data_release == 10:
-        baseurl = 'https://skyserver.sdss.org/dr{}/en/tools/crossid/x_crossid.aspx'
+        baseurl = f'https://skyserver.sdss.org/dr{data_release}/en/tools/crossid/x_crossid.aspx'
     if data_release == 11:
         return
     if data_release >= 12:
-        baseurl = 'https://skyserver.sdss.org/dr{}/en/tools/search/X_Results.aspx'
-    assert sdss.SDSS._last_url == baseurl.format(data_release)
+        baseurl = f'https://skyserver.sdss.org/dr{data_release}/en/tools/search/X_Results.aspx'
+    assert sdss.SDSS._last_url == baseurl
 
 
 def compare_xid_data(xid, data):
@@ -164,7 +164,7 @@ def image_tester(images, filetype):
 @pytest.mark.parametrize("dr", dr_list)
 def test_sdss_spectrum(patch_request, patch_get_readable_fileobj, dr,
                        coords=coords):
-    xid = sdss.SDSS.query_region(coords, data_release=dr, spectro=True)
+    xid = sdss.SDSS.query_region(coords, radius=Angle('2 arcsec'), data_release=dr, spectro=True)
     url_tester_crossid(dr)
     sp = sdss.SDSS.get_spectra(matches=xid, data_release=dr)
     image_tester(sp, 'spectra')
@@ -213,7 +213,7 @@ def test_sdss_sql(patch_request, patch_get_readable_fileobj, dr):
 @pytest.mark.parametrize("dr", dr_list)
 def test_sdss_image_from_query_region(patch_request, patch_get_readable_fileobj,
                                       dr, coords=coords):
-    xid = sdss.SDSS.query_region(coords, data_release=dr)
+    xid = sdss.SDSS.query_region(coords, radius=Angle('2 arcsec'), data_release=dr)
     url_tester_crossid(dr)
     # TODO test what img is
     img = sdss.SDSS.get_images(matches=xid)
@@ -272,25 +272,73 @@ def test_sdss_photoobj(patch_request, dr):
 
 
 @pytest.mark.parametrize("dr", dr_list)
-def test_list_coordinates(patch_request, dr):
-    xid = sdss.SDSS.query_region(coords_list, data_release=dr)
-
-    with warnings.catch_warnings():
-        if sys.platform.startswith('win'):
-            warnings.filterwarnings("ignore", category=AstropyWarning,
-                                    message=r'OverflowError converting.*')
-        data = Table.read(data_path(DATA_FILES['images_id']),
-                          format='ascii.csv', comment='#')
-
-        data['objid'] = data['objid'].astype(np.int64)
-
-        compare_xid_data(xid, data)
-        url_tester_crossid(dr)
+@pytest.mark.parametrize("radius", [None, Angle('2 arcsec')])
+@pytest.mark.parametrize("width", [None, Angle('2 arcsec')])
+def test_list_coordinates(patch_request, dr, radius, width):
+    if (radius is None and width is None):
+        with pytest.raises(ValueError) as e:
+            sdss.SDSS.query_region(coords, radius=radius, width=width)
+        assert str(e.value) == "Either radius or width must be specified!"
+    elif (radius is not None and width is not None):
+        with pytest.raises(ValueError) as e:
+            sdss.SDSS.query_region(coords, radius=radius, width=width)
+        assert str(e.value) == "Either radius or width must be specified, not both!"
+    else:
+        xid = sdss.SDSS.query_region(coords_list, radius=radius, width=width, data_release=dr)
+
+        with warnings.catch_warnings():
+            if sys.platform.startswith('win'):
+                warnings.filterwarnings("ignore", category=AstropyWarning,
+                                        message=r'OverflowError converting.*')
+            data = Table.read(data_path(DATA_FILES['images_id']),
+                              format='ascii.csv', comment='#')
+
+            data['objid'] = data['objid'].astype(np.int64)
+
+            compare_xid_data(xid, data)
+            if width is None:
+                url_tester_crossid(dr)
+            else:
+                url_tester(dr)
+
+
+@pytest.mark.parametrize("width", [Angle('2 arcsec'), 2.0 * u.arcsec, '2.0 arcsec', 'bad angle', '2.0 things'])
+@pytest.mark.parametrize("height", [None, Angle('2 arcsec'), 2.0 * u.arcsec, '2.0 arcsec', 'bad angle', '2.0 things'])
+def test_list_coordinates_with_height(patch_request, width, height):
+    if width == 'bad angle':
+        with pytest.raises(TypeError) as e:
+            sdss.SDSS.query_region(coords, width=width, height=height)
+        assert str(e.value) == 'Cannot parse "bad angle" as a Quantity. It does not start with a number.'
+    elif width == '2.0 things':
+        with pytest.raises(ValueError) as e:
+            sdss.SDSS.query_region(coords, width=width, height=height)
+        assert str(e.value).startswith("'things' did not parse as unit")
+    elif height == 'bad angle':
+        with pytest.raises(TypeError) as e:
+            sdss.SDSS.query_region(coords, width=width, height=height)
+        assert str(e.value) == 'Cannot parse "bad angle" as a Quantity. It does not start with a number.'
+    elif height == '2.0 things':
+        with pytest.raises(ValueError) as e:
+            sdss.SDSS.query_region(coords, width=width, height=height)
+        assert str(e.value).startswith("'things' did not parse as unit")
+    else:
+        xid = sdss.SDSS.query_region(coords_list, width=width, height=height)
+
+        with warnings.catch_warnings():
+            if sys.platform.startswith('win'):
+                warnings.filterwarnings("ignore", category=AstropyWarning,
+                                        message=r'OverflowError converting.*')
+            data = Table.read(data_path(DATA_FILES['images_id']),
+                              format='ascii.csv', comment='#')
+
+            data['objid'] = data['objid'].astype(np.int64)
+
+            compare_xid_data(xid, data)
 
 
 @pytest.mark.parametrize("dr", dr_list)
 def test_column_coordinates(patch_request, dr):
-    xid = sdss.SDSS.query_region(coords_column, data_release=dr)
+    xid = sdss.SDSS.query_region(coords_column, radius=Angle('2 arcsec'), data_release=dr)
 
     with warnings.catch_warnings():
         if sys.platform.startswith('win'):
@@ -305,9 +353,9 @@ def test_column_coordinates(patch_request, dr):
         url_tester_crossid(dr)
 
 
-def test_query_timeout(patch_request_slow, coord=coords):
+def test_query_timeout(patch_request_slow):
     with pytest.raises(TimeoutError):
-        sdss.SDSS.query_region(coords, timeout=1)
+        sdss.SDSS.query_region(coords, radius=Angle('2 arcsec'), timeout=1)
 
 
 def test_spectra_timeout(patch_request, patch_get_readable_fileobj_slow):
@@ -387,12 +435,66 @@ def test_list_coordinates_region_payload(patch_request, dr):
               "p.ra, p.dec, p.objid, p.run, p.rerun, p.camcol, p.field "
               "FROM #upload u JOIN #x x ON x.up_id = u.up_id JOIN PhotoObjAll AS p ON p.objID = x.objID "
               "ORDER BY x.up_id")
-    query_payload = sdss.SDSS.query_region(coords_list,
+    query_payload = sdss.SDSS.query_region(coords_list, radius=Angle('2 arcsec'),
                                            get_query_payload=True,
                                            data_release=dr)
     assert query_payload['uquery'] == expect
     assert query_payload['format'] == 'csv'
     assert query_payload['photoScope'] == 'allObj'
+    if dr > 11:
+        assert query_payload['searchtool'] == 'CrossID'
+
+
+@pytest.mark.parametrize("dr", dr_list)
+def test_list_coordinates_region_payload_rectangle(patch_request, dr):
+    expect = (" SELECT\r "
+              "p.ra, p.dec, p.objid, p.run, p.rerun, p.camcol, p.field "
+              "FROM PhotoObjAll AS p "
+              "WHERE (((p.ra BETWEEN 2.02319 AND 2.02374) AND (p.dec BETWEEN 14.8395 AND 14.8401)) "
+              "OR ((p.ra BETWEEN 2.02319 AND 2.02374) AND (p.dec BETWEEN 14.8395 AND 14.8401)))")
+    query_payload = sdss.SDSS.query_region(coords_list, width=Angle('2 arcsec'),
+                                           get_query_payload=True,
+                                           data_release=dr)
+    assert query_payload['cmd'] == expect
+    assert query_payload['format'] == 'csv'
+    if dr > 11:
+        assert query_payload['searchtool'] == 'SQL'
+
+
+@pytest.mark.parametrize("dr", dr_list)
+def test_list_coordinates_region_spectro_payload_rectangle(patch_request, dr):
+    expect = (" SELECT\r "
+              "p.ra, p.dec, p.objid, p.run, p.rerun, p.camcol, p.field, "
+              "s.z, s.plate, s.mjd, s.fiberID, s.specobjid, s.run2d "
+              "FROM PhotoObjAll AS p "
+              "JOIN SpecObjAll AS s ON p.objID = s.bestObjID "
+              "WHERE (((p.ra BETWEEN 2.02319 AND 2.02374) AND (p.dec BETWEEN 14.8395 AND 14.8401)) "
+              "OR ((p.ra BETWEEN 2.02319 AND 2.02374) AND (p.dec BETWEEN 14.8395 AND 14.8401)))")
+    query_payload = sdss.SDSS.query_region(coords_list, width=Angle('2 arcsec'),
+                                           spectro=True,
+                                           get_query_payload=True,
+                                           data_release=dr)
+    assert query_payload['cmd'] == expect
+    assert query_payload['format'] == 'csv'
+    if dr > 11:
+        assert query_payload['searchtool'] == 'SQL'
+
+
+@pytest.mark.parametrize("dr", dr_list)
+def test_coordinate_region_payload_rectangle(patch_request, dr):
+    expect = (" SELECT\r "
+              "p.ra, p.dec, p.objid, p.run, p.rerun, p.camcol, p.field "
+              "FROM PhotoObjAll AS p "
+              "WHERE ((((p.ra >= 359.999) OR (p.ra <= 0.00152171)) AND (p.dec BETWEEN 14.8356 AND 14.844)))")
+    query_payload = sdss.SDSS.query_region(SkyCoord("0h0m00.03s +14d50m23.3s", frame="icrs"),
+                                           width=Angle('10 arcsec'),
+                                           height=Angle('30 arcsec'),
+                                           get_query_payload=True,
+                                           data_release=dr)
+    assert query_payload['cmd'] == expect
+    assert query_payload['format'] == 'csv'
+    if dr > 11:
+        assert query_payload['searchtool'] == 'SQL'
 
 
 @pytest.mark.parametrize("dr", dr_list)
@@ -401,7 +503,7 @@ def test_column_coordinates_region_payload(patch_request, dr):
               "p.ra, p.dec, p.objid, p.run, p.rerun, p.camcol, p.field "
               "FROM #upload u JOIN #x x ON x.up_id = u.up_id JOIN PhotoObjAll AS p ON p.objID = x.objID "
               "ORDER BY x.up_id")
-    query_payload = sdss.SDSS.query_region(coords_column,
+    query_payload = sdss.SDSS.query_region(coords_column, radius=Angle('2 arcsec'),
                                            get_query_payload=True,
                                            data_release=dr)
     assert query_payload['uquery'] == expect
@@ -417,7 +519,8 @@ def test_column_coordinates_region_spectro_payload(patch_request, dr):
               "FROM #upload u JOIN #x x ON x.up_id = u.up_id JOIN PhotoObjAll AS p ON p.objID = x.objID "
               "JOIN SpecObjAll AS s ON p.objID = s.bestObjID "
               "ORDER BY x.up_id")
-    query_payload = sdss.SDSS.query_region(coords_column, spectro=True,
+    query_payload = sdss.SDSS.query_region(coords_column, radius=Angle('2 arcsec'),
+                                           spectro=True,
                                            get_query_payload=True,
                                            data_release=dr)
     assert query_payload['uquery'] == expect
@@ -431,7 +534,7 @@ def test_column_coordinates_region_payload_custom_fields(patch_request, dr):
               "p.r, p.psfMag_r "
               "FROM #upload u JOIN #x x ON x.up_id = u.up_id JOIN PhotoObjAll AS p ON p.objID = x.objID "
               "ORDER BY x.up_id")
-    query_payload = sdss.SDSS.query_region(coords_column,
+    query_payload = sdss.SDSS.query_region(coords_column, radius=Angle('2 arcsec'),
                                            get_query_payload=True,
                                            fields=['r', 'psfMag_r'],
                                            data_release=dr)
@@ -578,10 +681,10 @@ def test_field_help_region(patch_request):
     assert isinstance(valid_field, dict)
     assert 'photoobj_all' in valid_field
 
-    existing_p_field = sdss.SDSS.query_region(coords,
+    existing_p_field = sdss.SDSS.query_region(coords, radius=Angle('2 arcsec'),
                                               field_help='psfMag_r')
 
-    existing_s_field = sdss.SDSS.query_region(coords,
+    existing_s_field = sdss.SDSS.query_region(coords, radius=Angle('2 arcsec'),
                                               field_help='spectroSynFlux_r')
 
     with pytest.warns(UserWarning, match="nonexist isn't a valid 'photobj_field' or 'specobj_field'"):
@@ -592,3 +695,20 @@ def test_field_help_region(patch_request):
 
     assert len(non_existing_field) == 2
     assert set(non_existing_field.keys()) == set(('photoobj_all', 'specobj_all'))
+
+
+def test_rectangle_sql():
+    sql = sdss.SDSS._rectangle_sql(0, 0, 1)
+    assert sql == '(((p.ra >= 359.5) OR (p.ra <= 0.5)) AND (p.dec BETWEEN -0.5 AND 0.5))'
+    sql = sdss.SDSS._rectangle_sql(359, 0, 3, height=1)
+    assert sql == '(((p.ra >= 357.5) OR (p.ra <= 0.5)) AND (p.dec BETWEEN -0.5 AND 0.5))'
+    sql = sdss.SDSS._rectangle_sql(5, 0, 1)
+    assert sql == '((p.ra BETWEEN 4.5 AND 5.5) AND (p.dec BETWEEN -0.5 AND 0.5))'
+    sql = sdss.SDSS._rectangle_sql(5, 89.75, 1)
+    assert sql == '((p.ra BETWEEN 4.5 AND 5.5) AND (p.dec BETWEEN 89.25 AND 90))'
+    sql = sdss.SDSS._rectangle_sql(5, -89.75, 1)
+    assert sql == '((p.ra BETWEEN 4.5 AND 5.5) AND (p.dec BETWEEN -90 AND -89.25))'
+    sql = sdss.SDSS._rectangle_sql(5, 5, 1, height=2)
+    assert sql == '((p.ra BETWEEN 4.5 AND 5.5) AND (p.dec BETWEEN 4 AND 6))'
+    sql = sdss.SDSS._rectangle_sql(5, -5, 1, height=2)
+    assert sql == '((p.ra BETWEEN 4.5 AND 5.5) AND (p.dec BETWEEN -6 AND -4))'
diff --git a/astroquery/sdss/tests/test_sdss_remote.py b/astroquery/sdss/tests/test_sdss_remote.py
index ce37d8c9..48afd016 100644
--- a/astroquery/sdss/tests/test_sdss_remote.py
+++ b/astroquery/sdss/tests/test_sdss_remote.py
@@ -3,6 +3,7 @@ import numpy as np
 from numpy.testing import assert_allclose
 import pytest
 
+import astropy.units as u
 from astropy.coordinates import SkyCoord
 from astropy.table import Table
 from astropy.utils.exceptions import AstropyUserWarning
@@ -36,7 +37,7 @@ class TestSDSSRemote:
         This test *must* be run before `test_sdss_image` because that query
         caches!
         """
-        xid = sdss.SDSS.query_region(self.coords)
+        xid = sdss.SDSS.query_region(self.coords, width=2.0 * u.arcsec)
         assert len(xid) == 18
         try:
             with pytest.raises(TimeoutError):
@@ -51,9 +52,9 @@ class TestSDSSRemote:
     def test_sdss_spectrum(self, dr):
         if dr in dr_warn_list:
             with pytest.warns(AstropyUserWarning, match='Field info are not available for this data release'):
-                xid = sdss.SDSS.query_region(self.coords, spectro=True, data_release=dr)
+                xid = sdss.SDSS.query_region(self.coords, width=2.0 * u.arcsec, spectro=True, data_release=dr)
         else:
-            xid = sdss.SDSS.query_region(self.coords, spectro=True, data_release=dr)
+            xid = sdss.SDSS.query_region(self.coords, width=2.0 * u.arcsec, spectro=True, data_release=dr)
 
         assert isinstance(xid, Table)
         sdss.SDSS.get_spectra(matches=xid, data_release=dr)
@@ -85,7 +86,7 @@ class TestSDSSRemote:
         assert isinstance(xid, Table)
 
     def test_sdss_image(self):
-        xid = sdss.SDSS.query_region(self.coords)
+        xid = sdss.SDSS.query_region(self.coords, width=2.0 * u.arcsec)
         assert isinstance(xid, Table)
         sdss.SDSS.get_images(matches=xid)
 
@@ -170,14 +171,14 @@ class TestSDSSRemote:
 
     def test_query_non_default_field(self):
         # A regression test for #469
-        query1 = sdss.SDSS.query_region(self.coords, fields=['r', 'psfMag_r'])
+        query1 = sdss.SDSS.query_region(self.coords, width=2.0 * u.arcsec, fields=['r', 'psfMag_r'])
 
-        query2 = sdss.SDSS.query_region(self.coords, fields=['ra', 'dec', 'r'])
+        query2 = sdss.SDSS.query_region(self.coords, width=2.0 * u.arcsec, fields=['ra', 'dec', 'r'])
         assert isinstance(query1, Table)
         assert isinstance(query2, Table)
 
-        assert query1.colnames == ['objID', 'r', 'psfMag_r']
-        assert query2.colnames == ['objID', 'ra', 'dec', 'r']
+        assert query1.colnames == ['r', 'psfMag_r']
+        assert query2.colnames == ['ra', 'dec', 'r']
 
     @pytest.mark.parametrize("dr", dr_list)
     def test_query_crossid(self, dr):